From 5de8ee7bba12690d736abd457419d6829c759b3a Mon Sep 17 00:00:00 2001 From: bunnei Date: Thu, 29 Dec 2022 23:25:03 -0800 Subject: cmake: Integrate submoduled LLVM & fixes for Android. --- CMakeLists.txt | 56 +++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 53 insertions(+), 3 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index 7276ac9dd..bcd88784b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -11,6 +11,7 @@ list(APPEND CMAKE_MODULE_PATH "${CMAKE_CURRENT_SOURCE_DIR}/externals/cmake-modul include(DownloadExternals) include(CMakeDependentOption) include(CTest) +include(FetchContent) # Set bundled sdl2/qt as dependent options. # OFF by default, but if ENABLE_SDL2 and MSVC are true then ON @@ -19,7 +20,7 @@ CMAKE_DEPENDENT_OPTION(YUZU_USE_BUNDLED_SDL2 "Download bundled SDL2 binaries" ON # On Linux system SDL2 is likely to be lacking HIDAPI support which have drawbacks but is needed for SDL motion CMAKE_DEPENDENT_OPTION(YUZU_USE_EXTERNAL_SDL2 "Compile external SDL2" ON "ENABLE_SDL2;NOT MSVC" OFF) -option(ENABLE_LIBUSB "Enable the use of LibUSB" ON) +option(ENABLE_LIBUSB "Enable the use of LibUSB" "NOT ${ANDROID}") option(ENABLE_OPENGL "Enable OpenGL" ON) mark_as_advanced(FORCE ENABLE_OPENGL) @@ -48,7 +49,7 @@ option(YUZU_TESTS "Compile tests" "${BUILD_TESTING}") option(YUZU_USE_PRECOMPILED_HEADERS "Use precompiled headers" ON) -option(YUZU_ROOM "Compile LDN room server" ON) +option(YUZU_ROOM "Compile LDN room server" "NOT ${ANDROID}") CMAKE_DEPENDENT_OPTION(YUZU_CRASH_DUMPS "Compile Windows crash dump (Minidump) support" OFF "WIN32" OFF) @@ -60,7 +61,56 @@ option(YUZU_ENABLE_LTO "Enable link-time optimization" OFF) CMAKE_DEPENDENT_OPTION(YUZU_USE_FASTER_LD "Check if a faster linker is available" ON "NOT WIN32" OFF) +# On Android, fetch and compile libcxx before doing anything else +if (ANDROID) + set(CMAKE_SKIP_INSTALL_RULES ON) + set(LLVM_VERSION "15.0.6") + + # Note: even though libcxx and libcxxabi have separate releases on the project page, + # the separated releases cannot be compiled. Only in-tree builds work. Therefore we + # must fetch the source release for the entire llvm tree. + FetchContent_Declare(llvm + URL "https://github.com/llvm/llvm-project/releases/download/llvmorg-${LLVM_VERSION}/llvm-project-${LLVM_VERSION}.src.tar.xz" + URL_HASH SHA256=9d53ad04dc60cb7b30e810faf64c5ab8157dadef46c8766f67f286238256ff92 + TLS_VERIFY TRUE + ) + FetchContent_MakeAvailable(llvm) + + # libcxx has support for most of the range library, but it's gated behind a flag: + add_compile_definitions(_LIBCPP_ENABLE_EXPERIMENTAL) + + # Disable standard header inclusion + set(ANDROID_STL "none") + + # libcxxabi + set(LIBCXXABI_INCLUDE_TESTS OFF) + set(LIBCXXABI_ENABLE_SHARED FALSE) + set(LIBCXXABI_ENABLE_STATIC TRUE) + set(LIBCXXABI_LIBCXX_INCLUDES "${LIBCXX_TARGET_INCLUDE_DIRECTORY}" CACHE STRING "" FORCE) + add_subdirectory("${llvm_SOURCE_DIR}/libcxxabi" "${llvm_BINARY_DIR}/libcxxabi") + link_libraries(cxxabi_static) + + # libcxx + set(LIBCXX_ABI_NAMESPACE "__ndk1" CACHE STRING "" FORCE) + set(LIBCXX_CXX_ABI "libcxxabi") + set(LIBCXX_INCLUDE_TESTS OFF) + set(LIBCXX_INCLUDE_BENCHMARKS OFF) + set(LIBCXX_INCLUDE_DOCS OFF) + set(LIBCXX_ENABLE_SHARED FALSE) + set(LIBCXX_ENABLE_STATIC TRUE) + set(LIBCXX_ENABLE_ASSERTIONS FALSE) + add_subdirectory("${llvm_SOURCE_DIR}/libcxx" "${llvm_BINARY_DIR}/libcxx") + set_target_properties(cxx-headers PROPERTIES INTERFACE_COMPILE_OPTIONS "-isystem${CMAKE_BINARY_DIR}/${LIBCXX_INSTALL_INCLUDE_DIR}") + link_libraries(cxx_static cxx-headers) +endif() + if (YUZU_USE_BUNDLED_VCPKG) + if (ANDROID) + set(VCPKG_TARGET_TRIPLET "arm64-android") + set(ENV{ANDROID_NDK_HOME} "${ANDROID_NDK}") + list(APPEND VCPKG_MANIFEST_FEATURES "android") + endif() + if (YUZU_TESTS) list(APPEND VCPKG_MANIFEST_FEATURES "yuzu-tests") endif() @@ -457,7 +507,7 @@ set(FFmpeg_COMPONENTS avutil swscale) -if (UNIX AND NOT APPLE) +if (UNIX AND NOT APPLE AND NOT ANDROID) find_package(PkgConfig REQUIRED) pkg_check_modules(LIBVA libva) endif() -- cgit v1.2.3 From 851b1008a881010b90002f4af9d0a4ac36710fea Mon Sep 17 00:00:00 2001 From: bunnei Date: Fri, 30 Dec 2022 00:29:53 -0800 Subject: cmake: Integrate bundled FFmpeg for Android. --- CMakeModules/DownloadExternals.cmake | 7 +++- externals/ffmpeg/CMakeLists.txt | 64 +++++++++++++++++++++++++++++++----- src/video_core/CMakeLists.txt | 2 +- 3 files changed, 63 insertions(+), 10 deletions(-) diff --git a/CMakeModules/DownloadExternals.cmake b/CMakeModules/DownloadExternals.cmake index 8fe5ba48d..814069f0b 100644 --- a/CMakeModules/DownloadExternals.cmake +++ b/CMakeModules/DownloadExternals.cmake @@ -7,6 +7,7 @@ # prefix_var: name of a variable which will be set with the path to the extracted contents function(download_bundled_external remote_path lib_name prefix_var) +set(package_base_url "https://github.com/yuzu-emu/") set(package_repo "no_platform") set(package_extension "no_platform") if (WIN32) @@ -15,10 +16,14 @@ if (WIN32) elseif (${CMAKE_SYSTEM_NAME} STREQUAL "Linux") set(package_repo "ext-linux-bin/raw/main/") set(package_extension ".tar.xz") +elseif (ANDROID) + set(package_base_url "https://gitlab.com/tertius42/") + set(package_repo "ext-android-bin/-/raw/main/") + set(package_extension ".tar.xz") #ffmpeg/ffmpeg-android-20221229.tar.xz") else() message(FATAL_ERROR "No package available for this platform") endif() -set(package_url "https://github.com/yuzu-emu/${package_repo}") +set(package_url "${package_base_url}${package_repo}") set(prefix "${CMAKE_BINARY_DIR}/externals/${lib_name}") if (NOT EXISTS "${prefix}") diff --git a/externals/ffmpeg/CMakeLists.txt b/externals/ffmpeg/CMakeLists.txt index 03fad0778..093616629 100644 --- a/externals/ffmpeg/CMakeLists.txt +++ b/externals/ffmpeg/CMakeLists.txt @@ -1,7 +1,7 @@ # SPDX-FileCopyrightText: 2021 yuzu Emulator Project # SPDX-License-Identifier: GPL-2.0-or-later -if (NOT WIN32) +if (NOT WIN32 AND NOT ANDROID) # Build FFmpeg from externals message(STATUS "Using FFmpeg from externals") @@ -44,10 +44,12 @@ if (NOT WIN32) endforeach() find_package(PkgConfig REQUIRED) - pkg_check_modules(LIBVA libva) - pkg_check_modules(CUDA cuda) - pkg_check_modules(FFNVCODEC ffnvcodec) - pkg_check_modules(VDPAU vdpau) + if (NOT ANDROID) + pkg_check_modules(LIBVA libva) + pkg_check_modules(CUDA cuda) + pkg_check_modules(FFNVCODEC ffnvcodec) + pkg_check_modules(VDPAU vdpau) + endif() set(FFmpeg_HWACCEL_LIBRARIES) set(FFmpeg_HWACCEL_FLAGS) @@ -121,6 +123,26 @@ if (NOT WIN32) list(APPEND FFmpeg_HWACCEL_FLAGS --disable-vdpau) endif() + find_program(BASH_PROGRAM bash REQUIRED) + + set(FFmpeg_CROSS_COMPILE_FLAGS "") + if (ANDROID) + string(TOLOWER "${CMAKE_HOST_SYSTEM_NAME}" FFmpeg_HOST_SYSTEM_NAME) + set(TOOLCHAIN "${ANDROID_NDK}/toolchains/llvm/prebuilt/${FFmpeg_HOST_SYSTEM_NAME}-${CMAKE_HOST_SYSTEM_PROCESSOR}") + set(SYSROOT "${TOOLCHAIN}/sysroot") + set(FFmpeg_CPU "armv8-a") + list(APPEND FFmpeg_CROSS_COMPILE_FLAGS + --arch=arm64 + #--cpu=${FFmpeg_CPU} + --enable-cross-compile + --cross-prefix=${TOOLCHAIN}/bin/aarch64-linux-android- + --sysroot=${SYSROOT} + --target-os=android + --extra-ldflags="--ld-path=${TOOLCHAIN}/bin/ld.lld" + --extra-ldflags="-nostdlib" + ) + endif() + # `configure` parameters builds only exactly what yuzu needs from FFmpeg # `--disable-vdpau` is needed to avoid linking issues set(FFmpeg_CC ${CMAKE_C_COMPILER_LAUNCHER} ${CMAKE_C_COMPILER}) @@ -129,7 +151,7 @@ if (NOT WIN32) OUTPUT ${FFmpeg_MAKEFILE} COMMAND - /bin/bash ${FFmpeg_PREFIX}/configure + ${BASH_PROGRAM} ${FFmpeg_PREFIX}/configure --disable-avdevice --disable-avformat --disable-doc @@ -146,12 +168,14 @@ if (NOT WIN32) --cc="${FFmpeg_CC}" --cxx="${FFmpeg_CXX}" ${FFmpeg_HWACCEL_FLAGS} + ${FFmpeg_CROSS_COMPILE_FLAGS} WORKING_DIRECTORY ${FFmpeg_BUILD_DIR} ) unset(FFmpeg_CC) unset(FFmpeg_CXX) unset(FFmpeg_HWACCEL_FLAGS) + unset(FFmpeg_CROSS_COMPILE_FLAGS) # Workaround for Ubuntu 18.04's older version of make not being able to call make as a child # with context of the jobserver. Also helps ninja users. @@ -197,7 +221,32 @@ if (NOT WIN32) else() message(FATAL_ERROR "FFmpeg not found") endif() -else(WIN32) +elseif(ANDROID) + # Use yuzu FFmpeg binaries + set(FFmpeg_EXT_NAME "ffmpeg-android-v4.4.LTS") + set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") + download_bundled_external("ffmpeg/" ${FFmpeg_EXT_NAME} "") + set(FFmpeg_FOUND YES) + set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) + set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/lib" CACHE PATH "Path to FFmpeg library directory" FORCE) + set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) + set(FFmpeg_LIBRARIES + ${FFmpeg_LIBRARY_DIR}/libavcodec.so + ${FFmpeg_LIBRARY_DIR}/libavdevice.so + ${FFmpeg_LIBRARY_DIR}/libavfilter.so + ${FFmpeg_LIBRARY_DIR}/libavformat.so + ${FFmpeg_LIBRARY_DIR}/libavutil.so + ${FFmpeg_LIBRARY_DIR}/libswresample.so + ${FFmpeg_LIBRARY_DIR}/libswscale.so + ${FFmpeg_LIBRARY_DIR}/libvpx.a + ${FFmpeg_LIBRARY_DIR}/libx264.a + CACHE PATH "Paths to FFmpeg libraries" FORCE) + # exported variables + set(FFmpeg_PATH "${FFmpeg_PATH}" PARENT_SCOPE) + set(FFmpeg_LDFLAGS "${FFmpeg_LDFLAGS}" PARENT_SCOPE) + set(FFmpeg_LIBRARIES "${FFmpeg_LIBRARIES}" PARENT_SCOPE) + set(FFmpeg_INCLUDE_DIR "${FFmpeg_INCLUDE_DIR}" PARENT_SCOPE) +elseif(WIN32) # Use yuzu FFmpeg binaries set(FFmpeg_EXT_NAME "ffmpeg-5.1.3") set(FFmpeg_PATH "${CMAKE_BINARY_DIR}/externals/${FFmpeg_EXT_NAME}") @@ -206,7 +255,6 @@ else(WIN32) set(FFmpeg_INCLUDE_DIR "${FFmpeg_PATH}/include" CACHE PATH "Path to FFmpeg headers" FORCE) set(FFmpeg_LIBRARY_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg library directory" FORCE) set(FFmpeg_LDFLAGS "" CACHE STRING "FFmpeg linker flags" FORCE) - set(FFmpeg_DLL_DIR "${FFmpeg_PATH}/bin" CACHE PATH "Path to FFmpeg dll's" FORCE) set(FFmpeg_LIBRARIES ${FFmpeg_LIBRARY_DIR}/swscale.lib ${FFmpeg_LIBRARY_DIR}/avcodec.lib diff --git a/src/video_core/CMakeLists.txt b/src/video_core/CMakeLists.txt index 308d013d6..027259f57 100644 --- a/src/video_core/CMakeLists.txt +++ b/src/video_core/CMakeLists.txt @@ -281,7 +281,7 @@ create_target_directory_groups(video_core) target_link_libraries(video_core PUBLIC common core) target_link_libraries(video_core PUBLIC glad shader_recompiler stb) -if (YUZU_USE_BUNDLED_FFMPEG AND NOT WIN32) +if (YUZU_USE_BUNDLED_FFMPEG AND NOT (WIN32 OR ANDROID)) add_dependencies(video_core ffmpeg-build) endif() -- cgit v1.2.3 From bb2cbbfba3ba255c11953f2bcca912046519cfb1 Mon Sep 17 00:00:00 2001 From: bunnei Date: Sat, 17 Dec 2022 23:25:46 -0800 Subject: android: Add Citra frontend. --- src/android/.gitignore | 62 ++ src/android/app/build.gradle | 163 ++++ src/android/app/proguard-rules.pro | 21 + .../citra/citra_emu/ExampleInstrumentedTest.java | 3 + src/android/app/src/main/AndroidManifest.xml | 99 +++ .../java/org/citra/citra_emu/CitraApplication.java | 56 ++ .../java/org/citra/citra_emu/NativeLibrary.java | 631 +++++++++++++++ .../activities/CustomFilePickerActivity.java | 38 + .../citra_emu/activities/EmulationActivity.java | 755 ++++++++++++++++++ .../org/citra/citra_emu/adapters/GameAdapter.java | 247 ++++++ .../org/citra/citra_emu/applets/MiiSelector.java | 122 +++ .../citra/citra_emu/applets/SoftwareKeyboard.java | 264 +++++++ .../citra_emu/camera/StillImageCameraHelper.java | 65 ++ .../citra/citra_emu/dialogs/MotionAlertDialog.java | 140 ++++ .../disk_shader_cache/DiskShaderCacheProgress.java | 138 ++++ .../citra_emu/features/cheats/model/Cheat.java | 57 ++ .../features/cheats/model/CheatEngine.java | 13 + .../features/cheats/model/CheatsViewModel.java | 177 +++++ .../features/cheats/ui/CheatDetailsFragment.java | 174 ++++ .../features/cheats/ui/CheatListFragment.java | 46 ++ .../features/cheats/ui/CheatViewHolder.java | 56 ++ .../features/cheats/ui/CheatsActivity.java | 161 ++++ .../features/cheats/ui/CheatsAdapter.java | 72 ++ .../features/settings/model/BooleanSetting.java | 23 + .../features/settings/model/FloatSetting.java | 23 + .../features/settings/model/IntSetting.java | 23 + .../citra_emu/features/settings/model/Setting.java | 42 + .../features/settings/model/SettingSection.java | 55 ++ .../features/settings/model/Settings.java | 132 ++++ .../features/settings/model/StringSetting.java | 23 + .../settings/model/view/CheckBoxSetting.java | 80 ++ .../settings/model/view/DateTimeSetting.java | 40 + .../settings/model/view/HeaderSetting.java | 14 + .../settings/model/view/InputBindingSetting.java | 382 +++++++++ .../settings/model/view/PremiumHeader.java | 12 + .../model/view/PremiumSingleChoiceSetting.java | 59 ++ .../features/settings/model/view/SettingsItem.java | 107 +++ .../settings/model/view/SingleChoiceSetting.java | 60 ++ .../settings/model/view/SliderSetting.java | 101 +++ .../model/view/StringSingleChoiceSetting.java | 82 ++ .../settings/model/view/SubmenuSetting.java | 21 + .../features/settings/ui/SettingsActivity.java | 215 +++++ .../settings/ui/SettingsActivityPresenter.java | 124 +++ .../features/settings/ui/SettingsActivityView.java | 103 +++ .../features/settings/ui/SettingsAdapter.java | 487 ++++++++++++ .../features/settings/ui/SettingsFragment.java | 136 ++++ .../settings/ui/SettingsFragmentPresenter.java | 416 ++++++++++ .../features/settings/ui/SettingsFragmentView.java | 78 ++ .../features/settings/ui/SettingsFrameLayout.java | 48 ++ .../ui/viewholder/CheckBoxSettingViewHolder.java | 54 ++ .../settings/ui/viewholder/DateTimeViewHolder.java | 47 ++ .../settings/ui/viewholder/HeaderViewHolder.java | 32 + .../viewholder/InputBindingSettingViewHolder.java | 55 ++ .../settings/ui/viewholder/PremiumViewHolder.java | 57 ++ .../settings/ui/viewholder/SettingViewHolder.java | 49 ++ .../ui/viewholder/SingleChoiceViewHolder.java | 76 ++ .../settings/ui/viewholder/SliderViewHolder.java | 45 ++ .../settings/ui/viewholder/SubmenuViewHolder.java | 45 ++ .../features/settings/utils/SettingsFile.java | 341 ++++++++ .../fragments/CustomFilePickerFragment.java | 120 +++ .../citra_emu/fragments/EmulationFragment.java | 380 +++++++++ .../main/java/org/citra/citra_emu/model/Game.java | 76 ++ .../org/citra/citra_emu/model/GameDatabase.java | 276 +++++++ .../org/citra/citra_emu/model/GameProvider.java | 138 ++++ .../org/citra/citra_emu/overlay/InputOverlay.java | 878 +++++++++++++++++++++ .../overlay/InputOverlayDrawableButton.java | 122 +++ .../overlay/InputOverlayDrawableDpad.java | 193 +++++ .../overlay/InputOverlayDrawableJoystick.java | 264 +++++++ .../citra/citra_emu/ui/DividerItemDecoration.java | 130 +++ .../citra_emu/ui/TwoPaneOnBackPressedCallback.java | 37 + .../org/citra/citra_emu/ui/main/MainActivity.java | 267 +++++++ .../org/citra/citra_emu/ui/main/MainPresenter.java | 82 ++ .../java/org/citra/citra_emu/ui/main/MainView.java | 25 + .../ui/platform/PlatformGamesFragment.java | 86 ++ .../ui/platform/PlatformGamesPresenter.java | 42 + .../citra_emu/ui/platform/PlatformGamesView.java | 21 + .../java/org/citra/citra_emu/utils/Action1.java | 5 + .../citra/citra_emu/utils/AddDirectoryHelper.java | 38 + .../main/java/org/citra/citra_emu/utils/BiMap.java | 22 + .../org/citra/citra_emu/utils/BillingManager.java | 215 +++++ .../citra_emu/utils/ControllerMappingHelper.java | 66 ++ .../citra_emu/utils/DirectoryInitialization.java | 186 +++++ .../citra_emu/utils/DirectoryStateReceiver.java | 22 + .../citra_emu/utils/EmulationMenuSettings.java | 78 ++ .../citra/citra_emu/utils/FileBrowserHelper.java | 73 ++ .../java/org/citra/citra_emu/utils/FileUtil.java | 37 + .../citra/citra_emu/utils/ForegroundService.java | 63 ++ .../citra_emu/utils/GameIconRequestHandler.java | 27 + .../main/java/org/citra/citra_emu/utils/Log.java | 39 + .../citra/citra_emu/utils/PermissionsHandler.java | 35 + .../utils/PicassoRoundedCornersTransformation.java | 45 ++ .../org/citra/citra_emu/utils/PicassoUtils.java | 57 ++ .../org/citra/citra_emu/utils/StartupHandler.java | 45 ++ .../java/org/citra/citra_emu/utils/ThemeUtil.java | 34 + .../citra_emu/viewholders/GameViewHolder.java | 46 ++ .../app/src/main/res/animator/settings_enter.xml | 28 + .../app/src/main/res/animator/settings_exit.xml | 28 + .../src/main/res/animator/settings_pop_enter.xml | 28 + .../src/main/res/animator/setttings_pop_exit.xml | 27 + .../app/src/main/res/drawable-hdpi/button_a.png | Bin 0 -> 10674 bytes .../main/res/drawable-hdpi/button_a_pressed.png | Bin 0 -> 10738 bytes .../app/src/main/res/drawable-hdpi/button_b.png | Bin 0 -> 9479 bytes .../main/res/drawable-hdpi/button_b_pressed.png | Bin 0 -> 9555 bytes .../app/src/main/res/drawable-hdpi/button_l.png | Bin 0 -> 2738 bytes .../main/res/drawable-hdpi/button_l_pressed.png | Bin 0 -> 2795 bytes .../app/src/main/res/drawable-hdpi/button_r.png | Bin 0 -> 5680 bytes .../main/res/drawable-hdpi/button_r_pressed.png | Bin 0 -> 5784 bytes .../src/main/res/drawable-hdpi/button_select.png | Bin 0 -> 13280 bytes .../res/drawable-hdpi/button_select_pressed.png | Bin 0 -> 13344 bytes .../src/main/res/drawable-hdpi/button_start.png | Bin 0 -> 9518 bytes .../res/drawable-hdpi/button_start_pressed.png | Bin 0 -> 14872 bytes .../app/src/main/res/drawable-hdpi/button_x.png | Bin 0 -> 12124 bytes .../main/res/drawable-hdpi/button_x_pressed.png | Bin 0 -> 12390 bytes .../app/src/main/res/drawable-hdpi/button_y.png | Bin 0 -> 9321 bytes .../main/res/drawable-hdpi/button_y_pressed.png | Bin 0 -> 9498 bytes .../app/src/main/res/drawable-hdpi/button_zl.png | Bin 0 -> 4423 bytes .../main/res/drawable-hdpi/button_zl_pressed.png | Bin 0 -> 4426 bytes .../app/src/main/res/drawable-hdpi/button_zr.png | Bin 0 -> 6239 bytes .../main/res/drawable-hdpi/button_zr_pressed.png | Bin 0 -> 6201 bytes .../app/src/main/res/drawable-hdpi/dpad.png | Bin 0 -> 4273 bytes .../drawable-hdpi/dpad_pressed_one_direction.png | Bin 0 -> 3824 bytes .../drawable-hdpi/dpad_pressed_two_directions.png | Bin 0 -> 5658 bytes .../src/main/res/drawable-hdpi/ic_cia_install.png | Bin 0 -> 514 bytes .../app/src/main/res/drawable-hdpi/ic_folder.png | Bin 0 -> 275 bytes .../app/src/main/res/drawable-hdpi/ic_premium.png | Bin 0 -> 961 bytes .../main/res/drawable-hdpi/ic_settings_core.png | Bin 0 -> 793 bytes .../drawable-hdpi/ic_stat_notification_logo.png | Bin 0 -> 2824 bytes .../app/src/main/res/drawable-hdpi/stick_c.png | Bin 0 -> 14819 bytes .../src/main/res/drawable-hdpi/stick_c_pressed.png | Bin 0 -> 14825 bytes .../src/main/res/drawable-hdpi/stick_c_range.png | Bin 0 -> 8813 bytes .../app/src/main/res/drawable-hdpi/stick_main.png | Bin 0 -> 12828 bytes .../main/res/drawable-hdpi/stick_main_pressed.png | Bin 0 -> 8244 bytes .../main/res/drawable-hdpi/stick_main_range.png | Bin 0 -> 32592 bytes .../src/main/res/drawable-mdpi/ic_cia_install.png | Bin 0 -> 364 bytes .../app/src/main/res/drawable-mdpi/ic_folder.png | Bin 0 -> 214 bytes .../app/src/main/res/drawable-mdpi/ic_premium.png | Bin 0 -> 605 bytes .../res/drawable-night-hdpi/ic_cia_install.png | Bin 0 -> 556 bytes .../src/main/res/drawable-night-hdpi/ic_folder.png | Bin 0 -> 289 bytes .../main/res/drawable-night-hdpi/ic_premium.png | Bin 0 -> 955 bytes .../res/drawable-night-hdpi/ic_settings_core.png | Bin 0 -> 1152 bytes .../res/drawable-night-mdpi/ic_cia_install.png | Bin 0 -> 405 bytes .../src/main/res/drawable-night-mdpi/ic_folder.png | Bin 0 -> 227 bytes .../main/res/drawable-night-mdpi/ic_premium.png | Bin 0 -> 595 bytes .../res/drawable-night-xhdpi/ic_cia_install.png | Bin 0 -> 729 bytes .../main/res/drawable-night-xhdpi/ic_folder.png | Bin 0 -> 347 bytes .../main/res/drawable-night-xhdpi/ic_premium.png | Bin 0 -> 1281 bytes .../res/drawable-night-xhdpi/ic_settings_core.png | Bin 0 -> 1431 bytes .../res/drawable-night-xxhdpi/ic_cia_install.png | Bin 0 -> 1168 bytes .../main/res/drawable-night-xxhdpi/ic_folder.png | Bin 0 -> 555 bytes .../main/res/drawable-night-xxhdpi/ic_premium.png | Bin 0 -> 2049 bytes .../res/drawable-night-xxhdpi/ic_settings_core.png | Bin 0 -> 2125 bytes .../res/drawable-night-xxxhdpi/ic_cia_install.png | Bin 0 -> 1433 bytes .../main/res/drawable-night-xxxhdpi/ic_folder.png | Bin 0 -> 657 bytes .../main/res/drawable-night-xxxhdpi/ic_premium.png | Bin 0 -> 2614 bytes .../drawable-night-xxxhdpi/ic_settings_core.png | Bin 0 -> 2587 bytes .../app/src/main/res/drawable-night/no_icon.png | Bin 0 -> 9238 bytes .../app/src/main/res/drawable-xhdpi/button_a.png | Bin 0 -> 14645 bytes .../main/res/drawable-xhdpi/button_a_pressed.png | Bin 0 -> 14643 bytes .../app/src/main/res/drawable-xhdpi/button_b.png | Bin 0 -> 13040 bytes .../main/res/drawable-xhdpi/button_b_pressed.png | Bin 0 -> 13046 bytes .../app/src/main/res/drawable-xhdpi/button_l.png | Bin 0 -> 3461 bytes .../main/res/drawable-xhdpi/button_l_pressed.png | Bin 0 -> 3471 bytes .../app/src/main/res/drawable-xhdpi/button_r.png | Bin 0 -> 7603 bytes .../main/res/drawable-xhdpi/button_r_pressed.png | Bin 0 -> 7595 bytes .../src/main/res/drawable-xhdpi/button_select.png | Bin 0 -> 17681 bytes .../res/drawable-xhdpi/button_select_pressed.png | Bin 0 -> 17648 bytes .../src/main/res/drawable-xhdpi/button_start.png | Bin 0 -> 19588 bytes .../res/drawable-xhdpi/button_start_pressed.png | Bin 0 -> 19743 bytes .../app/src/main/res/drawable-xhdpi/button_x.png | Bin 0 -> 16315 bytes .../main/res/drawable-xhdpi/button_x_pressed.png | Bin 0 -> 16543 bytes .../app/src/main/res/drawable-xhdpi/button_y.png | Bin 0 -> 12529 bytes .../main/res/drawable-xhdpi/button_y_pressed.png | Bin 0 -> 12698 bytes .../app/src/main/res/drawable-xhdpi/button_zl.png | Bin 0 -> 5584 bytes .../main/res/drawable-xhdpi/button_zl_pressed.png | Bin 0 -> 5616 bytes .../app/src/main/res/drawable-xhdpi/button_zr.png | Bin 0 -> 8283 bytes .../main/res/drawable-xhdpi/button_zr_pressed.png | Bin 0 -> 8330 bytes .../app/src/main/res/drawable-xhdpi/dpad.png | Bin 0 -> 5296 bytes .../drawable-xhdpi/dpad_pressed_one_direction.png | Bin 0 -> 4781 bytes .../drawable-xhdpi/dpad_pressed_two_directions.png | Bin 0 -> 7857 bytes .../src/main/res/drawable-xhdpi/ic_cia_install.png | Bin 0 -> 656 bytes .../app/src/main/res/drawable-xhdpi/ic_folder.png | Bin 0 -> 325 bytes .../app/src/main/res/drawable-xhdpi/ic_premium.png | Bin 0 -> 1334 bytes .../main/res/drawable-xhdpi/ic_settings_core.png | Bin 0 -> 1029 bytes .../drawable-xhdpi/ic_stat_notification_logo.png | Bin 0 -> 4026 bytes .../app/src/main/res/drawable-xhdpi/stick_c.png | Bin 0 -> 23215 bytes .../main/res/drawable-xhdpi/stick_c_pressed.png | Bin 0 -> 20594 bytes .../src/main/res/drawable-xhdpi/stick_c_range.png | Bin 0 -> 18277 bytes .../app/src/main/res/drawable-xhdpi/stick_main.png | Bin 0 -> 19086 bytes .../main/res/drawable-xhdpi/stick_main_pressed.png | Bin 0 -> 11657 bytes .../main/res/drawable-xhdpi/stick_main_range.png | Bin 0 -> 53646 bytes .../app/src/main/res/drawable-xxhdpi/button_a.png | Bin 0 -> 23552 bytes .../main/res/drawable-xxhdpi/button_a_pressed.png | Bin 0 -> 23611 bytes .../app/src/main/res/drawable-xxhdpi/button_b.png | Bin 0 -> 20371 bytes .../main/res/drawable-xxhdpi/button_b_pressed.png | Bin 0 -> 20591 bytes .../app/src/main/res/drawable-xxhdpi/button_l.png | Bin 0 -> 5288 bytes .../main/res/drawable-xxhdpi/button_l_pressed.png | Bin 0 -> 5352 bytes .../app/src/main/res/drawable-xxhdpi/button_r.png | Bin 0 -> 11960 bytes .../main/res/drawable-xxhdpi/button_r_pressed.png | Bin 0 -> 11969 bytes .../src/main/res/drawable-xxhdpi/button_select.png | Bin 0 -> 27251 bytes .../res/drawable-xxhdpi/button_select_pressed.png | Bin 0 -> 27436 bytes .../src/main/res/drawable-xxhdpi/button_start.png | Bin 0 -> 30505 bytes .../res/drawable-xxhdpi/button_start_pressed.png | Bin 0 -> 30785 bytes .../app/src/main/res/drawable-xxhdpi/button_x.png | Bin 0 -> 27021 bytes .../main/res/drawable-xxhdpi/button_x_pressed.png | Bin 0 -> 27645 bytes .../app/src/main/res/drawable-xxhdpi/button_y.png | Bin 0 -> 19978 bytes .../main/res/drawable-xxhdpi/button_y_pressed.png | Bin 0 -> 20426 bytes .../app/src/main/res/drawable-xxhdpi/button_zl.png | Bin 0 -> 8675 bytes .../main/res/drawable-xxhdpi/button_zl_pressed.png | Bin 0 -> 8675 bytes .../app/src/main/res/drawable-xxhdpi/button_zr.png | Bin 0 -> 13105 bytes .../main/res/drawable-xxhdpi/button_zr_pressed.png | Bin 0 -> 13182 bytes .../app/src/main/res/drawable-xxhdpi/dpad.png | Bin 0 -> 7816 bytes .../drawable-xxhdpi/dpad_pressed_one_direction.png | Bin 0 -> 6977 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 12762 bytes .../main/res/drawable-xxhdpi/ic_cia_install.png | Bin 0 -> 967 bytes .../app/src/main/res/drawable-xxhdpi/ic_folder.png | Bin 0 -> 487 bytes .../src/main/res/drawable-xxhdpi/ic_premium.png | Bin 0 -> 2096 bytes .../main/res/drawable-xxhdpi/ic_settings_core.png | Bin 0 -> 1647 bytes .../drawable-xxhdpi/ic_stat_notification_logo.png | Bin 0 -> 5936 bytes .../app/src/main/res/drawable-xxhdpi/stick_c.png | Bin 0 -> 41218 bytes .../main/res/drawable-xxhdpi/stick_c_pressed.png | Bin 0 -> 32729 bytes .../src/main/res/drawable-xxhdpi/stick_c_range.png | Bin 0 -> 28519 bytes .../src/main/res/drawable-xxhdpi/stick_main.png | Bin 0 -> 35658 bytes .../res/drawable-xxhdpi/stick_main_pressed.png | Bin 0 -> 19150 bytes .../main/res/drawable-xxhdpi/stick_main_range.png | Bin 0 -> 99656 bytes .../app/src/main/res/drawable-xxxhdpi/button_a.png | Bin 0 -> 29133 bytes .../main/res/drawable-xxxhdpi/button_a_pressed.png | Bin 0 -> 29190 bytes .../app/src/main/res/drawable-xxxhdpi/button_b.png | Bin 0 -> 24653 bytes .../main/res/drawable-xxxhdpi/button_b_pressed.png | Bin 0 -> 24931 bytes .../app/src/main/res/drawable-xxxhdpi/button_l.png | Bin 0 -> 6396 bytes .../main/res/drawable-xxxhdpi/button_l_pressed.png | Bin 0 -> 6455 bytes .../app/src/main/res/drawable-xxxhdpi/button_r.png | Bin 0 -> 14580 bytes .../main/res/drawable-xxxhdpi/button_r_pressed.png | Bin 0 -> 14493 bytes .../main/res/drawable-xxxhdpi/button_select.png | Bin 0 -> 32098 bytes .../res/drawable-xxxhdpi/button_select_pressed.png | Bin 0 -> 32299 bytes .../src/main/res/drawable-xxxhdpi/button_start.png | Bin 0 -> 36683 bytes .../res/drawable-xxxhdpi/button_start_pressed.png | Bin 0 -> 36775 bytes .../app/src/main/res/drawable-xxxhdpi/button_x.png | Bin 0 -> 33016 bytes .../main/res/drawable-xxxhdpi/button_x_pressed.png | Bin 0 -> 34053 bytes .../app/src/main/res/drawable-xxxhdpi/button_y.png | Bin 0 -> 24127 bytes .../main/res/drawable-xxxhdpi/button_y_pressed.png | Bin 0 -> 24408 bytes .../src/main/res/drawable-xxxhdpi/button_zl.png | Bin 0 -> 10479 bytes .../res/drawable-xxxhdpi/button_zl_pressed.png | Bin 0 -> 10484 bytes .../src/main/res/drawable-xxxhdpi/button_zr.png | Bin 0 -> 15653 bytes .../res/drawable-xxxhdpi/button_zr_pressed.png | Bin 0 -> 15648 bytes .../app/src/main/res/drawable-xxxhdpi/dpad.png | Bin 0 -> 9253 bytes .../dpad_pressed_one_direction.png | Bin 0 -> 8434 bytes .../dpad_pressed_two_directions.png | Bin 0 -> 16159 bytes .../main/res/drawable-xxxhdpi/ic_cia_install.png | Bin 0 -> 1244 bytes .../src/main/res/drawable-xxxhdpi/ic_folder.png | Bin 0 -> 591 bytes .../src/main/res/drawable-xxxhdpi/ic_premium.png | Bin 0 -> 2654 bytes .../main/res/drawable-xxxhdpi/ic_settings_core.png | Bin 0 -> 2093 bytes .../app/src/main/res/drawable-xxxhdpi/stick_c.png | Bin 0 -> 57013 bytes .../main/res/drawable-xxxhdpi/stick_c_pressed.png | Bin 0 -> 40273 bytes .../main/res/drawable-xxxhdpi/stick_c_range.png | Bin 0 -> 34281 bytes .../src/main/res/drawable-xxxhdpi/stick_main.png | Bin 0 -> 45881 bytes .../res/drawable-xxxhdpi/stick_main_pressed.png | Bin 0 -> 24942 bytes .../main/res/drawable-xxxhdpi/stick_main_range.png | Bin 0 -> 136109 bytes .../app/src/main/res/drawable/gamelist_divider.xml | 11 + src/android/app/src/main/res/drawable/ic_add.xml | 9 + src/android/app/src/main/res/drawable/no_icon.png | Bin 0 -> 8610 bytes .../src/main/res/layout-ldrtl/list_item_cheat.xml | 38 + .../app/src/main/res/layout/activity_cheats.xml | 22 + .../app/src/main/res/layout/activity_emulation.xml | 17 + .../app/src/main/res/layout/activity_main.xml | 27 + .../app/src/main/res/layout/activity_settings.xml | 5 + src/android/app/src/main/res/layout/card_game.xml | 81 ++ .../app/src/main/res/layout/dialog_checkbox.xml | 16 + .../src/main/res/layout/dialog_progress_bar.xml | 26 + .../app/src/main/res/layout/dialog_seekbar.xml | 37 + .../app/src/main/res/layout/filepicker_toolbar.xml | 32 + .../src/main/res/layout/fragment_cheat_details.xml | 163 ++++ .../src/main/res/layout/fragment_cheat_list.xml | 27 + .../app/src/main/res/layout/fragment_emulation.xml | 47 ++ .../app/src/main/res/layout/fragment_grid.xml | 33 + .../app/src/main/res/layout/fragment_settings.xml | 12 + .../app/src/main/res/layout/list_item_cheat.xml | 38 + .../app/src/main/res/layout/list_item_setting.xml | 43 + .../main/res/layout/list_item_setting_checkbox.xml | 52 ++ .../main/res/layout/list_item_settings_header.xml | 19 + .../src/main/res/layout/premium_item_setting.xml | 43 + .../main/res/layout/sysclock_datetime_picker.xml | 22 + .../app/src/main/res/menu/menu_emulation.xml | 118 +++ .../app/src/main/res/menu/menu_game_grid.xml | 34 + .../app/src/main/res/menu/menu_settings.xml | 2 + .../src/main/res/mipmap-anydpi-v26/ic_launcher.xml | 5 + .../app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 5899 bytes .../res/mipmap-hdpi/ic_launcher_foreground.png | Bin 0 -> 7416 bytes .../app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 3377 bytes .../res/mipmap-mdpi/ic_launcher_foreground.png | Bin 0 -> 4413 bytes .../app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 8742 bytes .../res/mipmap-xhdpi/ic_launcher_foreground.png | Bin 0 -> 10530 bytes .../app/src/main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 14300 bytes .../res/mipmap-xxhdpi/ic_launcher_foreground.png | Bin 0 -> 17511 bytes .../src/main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 20804 bytes .../res/mipmap-xxxhdpi/ic_launcher_foreground.png | Bin 0 -> 24886 bytes .../app/src/main/res/values-night/colors.xml | 17 + .../main/res/values-night/styles_filepicker.xml | 5 + .../app/src/main/res/values-w1000dp/integers.xml | 4 + .../app/src/main/res/values-w1050dp/dimens.xml | 6 + .../app/src/main/res/values-w500dp/integers.xml | 4 + .../app/src/main/res/values-w750dp/integers.xml | 4 + .../app/src/main/res/values-w820dp/dimens.xml | 5 + src/android/app/src/main/res/values/arrays.xml | 174 ++++ src/android/app/src/main/res/values/colors.xml | 17 + src/android/app/src/main/res/values/dimens.xml | 10 + .../src/main/res/values/ic_launcher_background.xml | 4 + src/android/app/src/main/res/values/integers.xml | 65 ++ src/android/app/src/main/res/values/strings.xml | 246 ++++++ src/android/app/src/main/res/values/styles.xml | 65 ++ .../app/src/main/res/values/styles_filepicker.xml | 5 + .../java/org/citra/citra_emu/ExampleUnitTest.java | 17 + src/android/build.gradle | 26 + src/android/code-style-java.xml | 240 ++++++ src/android/gradle.properties | 15 + src/android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54708 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + src/android/gradlew | 172 ++++ src/android/gradlew.bat | 84 ++ src/android/settings.gradle | 1 + 319 files changed, 13799 insertions(+) create mode 100644 src/android/.gitignore create mode 100644 src/android/app/build.gradle create mode 100644 src/android/app/proguard-rules.pro create mode 100644 src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java create mode 100644 src/android/app/src/main/AndroidManifest.xml create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/Game.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableButton.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableDpad.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlayDrawableJoystick.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/DividerItemDecoration.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesFragment.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesPresenter.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/ui/platform/PlatformGamesView.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Action1.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/AddDirectoryHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BiMap.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ControllerMappingHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryInitialization.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/DirectoryStateReceiver.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/EmulationMenuSettings.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileBrowserHelper.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/FileUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ForegroundService.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/GameIconRequestHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/Log.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PermissionsHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoRoundedCornersTransformation.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/PicassoUtils.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/StartupHandler.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/ThemeUtil.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/viewholders/GameViewHolder.java create mode 100644 src/android/app/src/main/res/animator/settings_enter.xml create mode 100644 src/android/app/src/main/res/animator/settings_exit.xml create mode 100644 src/android/app/src/main/res/animator/settings_pop_enter.xml create mode 100644 src/android/app/src/main/res/animator/setttings_pop_exit.xml create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-hdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-mdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-night/no_icon.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_a.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_b.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_l.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_r.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_select.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_start.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_x.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_y.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png create mode 100644 src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png create mode 100644 src/android/app/src/main/res/drawable/gamelist_divider.xml create mode 100644 src/android/app/src/main/res/drawable/ic_add.xml create mode 100644 src/android/app/src/main/res/drawable/no_icon.png create mode 100644 src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml create mode 100644 src/android/app/src/main/res/layout/activity_cheats.xml create mode 100644 src/android/app/src/main/res/layout/activity_emulation.xml create mode 100644 src/android/app/src/main/res/layout/activity_main.xml create mode 100644 src/android/app/src/main/res/layout/activity_settings.xml create mode 100644 src/android/app/src/main/res/layout/card_game.xml create mode 100644 src/android/app/src/main/res/layout/dialog_checkbox.xml create mode 100644 src/android/app/src/main/res/layout/dialog_progress_bar.xml create mode 100644 src/android/app/src/main/res/layout/dialog_seekbar.xml create mode 100644 src/android/app/src/main/res/layout/filepicker_toolbar.xml create mode 100644 src/android/app/src/main/res/layout/fragment_cheat_details.xml create mode 100644 src/android/app/src/main/res/layout/fragment_cheat_list.xml create mode 100644 src/android/app/src/main/res/layout/fragment_emulation.xml create mode 100644 src/android/app/src/main/res/layout/fragment_grid.xml create mode 100644 src/android/app/src/main/res/layout/fragment_settings.xml create mode 100644 src/android/app/src/main/res/layout/list_item_cheat.xml create mode 100644 src/android/app/src/main/res/layout/list_item_setting.xml create mode 100644 src/android/app/src/main/res/layout/list_item_setting_checkbox.xml create mode 100644 src/android/app/src/main/res/layout/list_item_settings_header.xml create mode 100644 src/android/app/src/main/res/layout/premium_item_setting.xml create mode 100644 src/android/app/src/main/res/layout/sysclock_datetime_picker.xml create mode 100644 src/android/app/src/main/res/menu/menu_emulation.xml create mode 100644 src/android/app/src/main/res/menu/menu_game_grid.xml create mode 100644 src/android/app/src/main/res/menu/menu_settings.xml create mode 100644 src/android/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml create mode 100644 src/android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-hdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-mdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xxhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 src/android/app/src/main/res/mipmap-xxxhdpi/ic_launcher_foreground.png create mode 100644 src/android/app/src/main/res/values-night/colors.xml create mode 100644 src/android/app/src/main/res/values-night/styles_filepicker.xml create mode 100644 src/android/app/src/main/res/values-w1000dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w1050dp/dimens.xml create mode 100644 src/android/app/src/main/res/values-w500dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w750dp/integers.xml create mode 100644 src/android/app/src/main/res/values-w820dp/dimens.xml create mode 100644 src/android/app/src/main/res/values/arrays.xml create mode 100644 src/android/app/src/main/res/values/colors.xml create mode 100644 src/android/app/src/main/res/values/dimens.xml create mode 100644 src/android/app/src/main/res/values/ic_launcher_background.xml create mode 100644 src/android/app/src/main/res/values/integers.xml create mode 100644 src/android/app/src/main/res/values/strings.xml create mode 100644 src/android/app/src/main/res/values/styles.xml create mode 100644 src/android/app/src/main/res/values/styles_filepicker.xml create mode 100644 src/android/app/src/test/java/org/citra/citra_emu/ExampleUnitTest.java create mode 100644 src/android/build.gradle create mode 100644 src/android/code-style-java.xml create mode 100644 src/android/gradle.properties create mode 100644 src/android/gradle/wrapper/gradle-wrapper.jar create mode 100644 src/android/gradle/wrapper/gradle-wrapper.properties create mode 100644 src/android/gradlew create mode 100644 src/android/gradlew.bat create mode 100644 src/android/settings.gradle diff --git a/src/android/.gitignore b/src/android/.gitignore new file mode 100644 index 000000000..40b6c5cd0 --- /dev/null +++ b/src/android/.gitignore @@ -0,0 +1,62 @@ +# Built application files +*.apk +*.ap_ + +# Files for the ART/Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +bin/ +gen/ +out/ + +# Gradle files +.gradle/ +build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +.navigation/ + +# Android Studio captures folder +captures/ + +# IntelliJ +*.iml +.idea/ + +# Keystore files +# Uncomment the following line if you do not want to check your keystore files in. +#*.jks + +# External native build folder generated in Android Studio 2.2 and later +.externalNativeBuild + +# CXX compile cache +app/.cxx + +# Google Services (e.g. APIs or Firebase) +google-services.json + +# Freeline +freeline.py +freeline/ +freeline_project_description.json + +# fastlane +fastlane/report.xml +fastlane/Preview.html +fastlane/screenshots +fastlane/test_output +fastlane/readme.md diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle new file mode 100644 index 000000000..5a108743b --- /dev/null +++ b/src/android/app/build.gradle @@ -0,0 +1,163 @@ +apply plugin: 'com.android.application' + +/** + * Use the number of seconds/10 since Jan 1 2016 as the versionCode. + * This lets us upload a new build at most every 10 seconds for the + * next 680 years. + */ +def autoVersion = (int) (((new Date().getTime() / 1000) - 1451606400) / 10) +def buildType +def abiFilter = "arm64-v8a" //, "x86" + +android { + compileSdkVersion 32 + ndkVersion "25.1.8937393" + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + // This is important as it will run lint but not abort on error + // Lint has some overly obnoxious "errors" that should really be warnings + abortOnError false + + //Uncomment disable lines for test builds... + //disable 'MissingTranslation'bin + //disable 'ExtraTranslation' + } + + defaultConfig { + // TODO If this is ever modified, change application_id in strings.xml + applicationId "org.citra.citra_emu" + minSdkVersion 28 + targetSdkVersion 29 + versionCode autoVersion + versionName getVersion() + ndk.abiFilters abiFilter + } + + signingConfigs { + //release { + // storeFile file('') + // storePassword System.getenv('ANDROID_KEYPASS') + // keyAlias = 'key0' + // keyPassword System.getenv('ANDROID_KEYPASS') + //} + } + + applicationVariants.all { variant -> + buildType = variant.buildType.name // sets the current build type + } + + // Define build types, which are orthogonal to product flavors. + buildTypes { + + // Signed by release key, allowing for upload to Play Store. + release { + signingConfig signingConfigs.debug + } + + // builds a release build that doesn't need signing + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + relWithDebInfo { + initWith release + applicationIdSuffix ".debug" + versionNameSuffix '-debug' + signingConfig signingConfigs.debug + minifyEnabled false + testCoverageEnabled false + debuggable true + jniDebuggable true + } + + // Signed by debug key disallowing distribution on Play Store. + // Attaches 'debug' suffix to version and package name, allowing installation alongside the release build. + debug { + // TODO If this is ever modified, change application_id in debug/strings.xml + applicationIdSuffix ".debug" + versionNameSuffix '-debug' + debuggable true + jniDebuggable true + } + } + + flavorDimensions "version" + productFlavors { + canary { + dimension "version" + applicationIdSuffix ".canary" + } + nightly { + dimension "version" + } + } + + externalNativeBuild { + cmake { + version "3.22.1" + path "../../../CMakeLists.txt" + } + } + + defaultConfig { + externalNativeBuild { + cmake { + arguments "-DENABLE_QT=0", // Don't use QT + "-DENABLE_SDL2=0", // Don't use SDL + "-DENABLE_WEB_SERVICE=0", // Don't use telemetry + "-DANDROID_ARM_NEON=true", // cryptopp requires Neon to work + "-DYUZU_USE_BUNDLED_VCPKG=ON", + "-DYUZU_USE_BUNDLED_FFMPEG=ON" + + abiFilters abiFilter + } + } + } +} + +dependencies { + implementation 'androidx.appcompat:appcompat:1.5.1' + implementation 'androidx.exifinterface:exifinterface:1.3.4' + implementation 'androidx.cardview:cardview:1.0.0' + implementation 'androidx.recyclerview:recyclerview:1.2.1' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.lifecycle:lifecycle-viewmodel:2.5.1' + implementation 'androidx.fragment:fragment:1.5.3' + implementation "androidx.slidingpanelayout:slidingpanelayout:1.2.0" + implementation 'com.google.android.material:material:1.6.1' + + // For loading huge screenshots from the disk. + implementation 'com.squareup.picasso:picasso:2.71828' + + // Allows FRP-style asynchronous operations in Android. + implementation 'io.reactivex:rxandroid:1.2.1' + implementation 'com.nononsenseapps:filepicker:4.2.1' + implementation 'org.ini4j:ini4j:0.5.4' + implementation 'androidx.constraintlayout:constraintlayout:2.1.4' + implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.1.0' + implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + + // Please don't upgrade the billing library as the newer version is not GPL-compatible + implementation 'com.android.billingclient:billing:2.0.3' +} + +def getVersion() { + def versionName = '0.0' + + try { + versionName = 'git describe --always --long'.execute([], project.rootDir).text + .trim() + .replaceAll(/(-0)?-[^-]+$/, "") + } catch (Exception) { + logger.error('Cannot find git, defaulting to dummy version number') + } + + if (System.getenv("GITHUB_ACTIONS") != null) { + def gitTag = System.getenv("GIT_TAG_NAME") + versionName = gitTag ?: versionName + } + + return versionName +} diff --git a/src/android/app/proguard-rules.pro b/src/android/app/proguard-rules.pro new file mode 100644 index 000000000..f1b424510 --- /dev/null +++ b/src/android/app/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile diff --git a/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java new file mode 100644 index 000000000..6a25f2ce6 --- /dev/null +++ b/src/android/app/src/androidTest/java/org/citra/citra_emu/ExampleInstrumentedTest.java @@ -0,0 +1,3 @@ +package org.citra.citra_emu; + +import android.content.Context; diff --git a/src/android/app/src/main/AndroidManifest.xml b/src/android/app/src/main/AndroidManifest.xml new file mode 100644 index 000000000..c2463e079 --- /dev/null +++ b/src/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,99 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java new file mode 100644 index 000000000..41ac7e27c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/CitraApplication.java @@ -0,0 +1,56 @@ +// Copyright 2019 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu; + +import android.app.Application; +import android.app.NotificationChannel; +import android.app.NotificationManager; +import android.content.Context; +import android.os.Build; + +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.PermissionsHandler; + +public class CitraApplication extends Application { + public static GameDatabase databaseHelper; + private static CitraApplication application; + + private void createNotificationChannel() { + // Create the NotificationChannel, but only on API 26+ because + // the NotificationChannel class is new and not in the support library + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + CharSequence name = getString(R.string.app_notification_channel_name); + String description = getString(R.string.app_notification_channel_description); + NotificationChannel channel = new NotificationChannel(getString(R.string.app_notification_channel_id), name, NotificationManager.IMPORTANCE_LOW); + channel.setDescription(description); + channel.setSound(null, null); + channel.setVibrationPattern(null); + // Register the channel with the system; you can't change the importance + // or other notification behaviors after this + NotificationManager notificationManager = getSystemService(NotificationManager.class); + notificationManager.createNotificationChannel(channel); + } + } + + @Override + public void onCreate() { + super.onCreate(); + application = this; + + if (PermissionsHandler.hasWriteAccess(getApplicationContext())) { + DirectoryInitialization.start(getApplicationContext()); + } + + NativeLibrary.LogDeviceInfo(); + createNotificationChannel(); + + databaseHelper = new GameDatabase(this); + } + + public static Context getAppContext() { + return application.getApplicationContext(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java new file mode 100644 index 000000000..baff99dc8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/NativeLibrary.java @@ -0,0 +1,631 @@ +/* + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu; + +import android.app.Activity; +import android.app.Dialog; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.os.Bundle; +import android.text.Html; +import android.text.method.LinkMovementMethod; +import android.view.Surface; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; + +import java.lang.ref.WeakReference; +import java.util.Objects; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; + +/** + * Class which contains methods that interact + * with the native side of the Citra code. + */ +public final class NativeLibrary { + /** + * Default touchscreen device + */ + public static final String TouchScreenDevice = "Touchscreen"; + public static WeakReference sEmulationActivity = new WeakReference<>(null); + + private static boolean alertResult = false; + private static String alertPromptResult = ""; + private static int alertPromptButton = 0; + private static final Object alertPromptLock = new Object(); + private static boolean alertPromptInProgress = false; + private static String alertPromptCaption = ""; + private static int alertPromptButtonConfig = 0; + private static EditText alertPromptEditText = null; + + static { + try { + System.loadLibrary("yuzu-android"); + } catch (UnsatisfiedLinkError ex) { + Log.error("[NativeLibrary] " + ex.toString()); + } + } + + private NativeLibrary() { + // Disallows instantiation. + } + + /** + * Handles button press events for a gamepad. + * + * @param Device The input descriptor of the gamepad. + * @param Button Key code identifying which button was pressed. + * @param Action Mask identifying which action is happening (button pressed down, or button released). + * @return If we handled the button press. + */ + public static native boolean onGamePadEvent(String Device, int Button, int Action); + + /** + * Handles gamepad movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis The axis ID + * @param x_axis The value of the x-axis represented by the given ID. + * @param y_axis The value of the y-axis represented by the given ID + */ + public static native boolean onGamePadMoveEvent(String Device, int Axis, float x_axis, float y_axis); + + /** + * Handles gamepad movement events. + * + * @param Device The device ID of the gamepad. + * @param Axis_id The axis ID + * @param axis_val The value of the axis represented by the given ID. + */ + public static native boolean onGamePadAxisEvent(String Device, int Axis_id, float axis_val); + + /** + * Handles touch events. + * + * @param x_axis The value of the x-axis. + * @param y_axis The value of the y-axis + * @param pressed To identify if the touch held down or released. + * @return true if the pointer is within the touchscreen + */ + public static native boolean onTouchEvent(float x_axis, float y_axis, boolean pressed); + + /** + * Handles touch movement. + * + * @param x_axis The value of the instantaneous x-axis. + * @param y_axis The value of the instantaneous y-axis. + */ + public static native void onTouchMoved(float x_axis, float y_axis); + + public static native void ReloadSettings(); + + public static native String GetUserSetting(String gameID, String Section, String Key); + + public static native void SetUserSetting(String gameID, String Section, String Key, String Value); + + public static native void InitGameIni(String gameID); + + /** + * Gets the embedded icon within the given ROM. + * + * @param filename the file path to the ROM. + * @return an integer array containing the color data for the icon. + */ + public static native int[] GetIcon(String filename); + + /** + * Gets the embedded title of the given ISO/ROM. + * + * @param filename The file path to the ISO/ROM. + * @return the embedded title of the ISO/ROM. + */ + public static native String GetTitle(String filename); + + public static native String GetDescription(String filename); + + public static native String GetGameId(String filename); + + public static native String GetRegions(String filename); + + public static native String GetCompany(String filename); + + public static native String GetGitRevision(); + + /** + * Sets the current working user directory + * If not set, it auto-detects a location + */ + public static native void SetUserDirectory(String directory); + + // Create the config.ini file. + public static native void CreateConfigFile(); + + public static native int DefaultCPUCore(); + + /** + * Begins emulation. + */ + public static native void Run(String path); + + /** + * Begins emulation from the specified savestate. + */ + public static native void Run(String path, String savestatePath, boolean deleteSavestate); + + // Surface Handling + public static native void SurfaceChanged(Surface surf); + + public static native void SurfaceDestroyed(); + + public static native void DoFrame(); + + /** + * Unpauses emulation from a paused state. + */ + public static native void UnPauseEmulation(); + + /** + * Pauses emulation. + */ + public static native void PauseEmulation(); + + /** + * Stops emulation. + */ + public static native void StopEmulation(); + + /** + * Returns true if emulation is running (or is paused). + */ + public static native boolean IsRunning(); + + /** + * Returns the performance stats for the current game + **/ + public static native double[] GetPerfStats(); + + /** + * Notifies the core emulation that the orientation has changed. + */ + public static native void NotifyOrientationChange(int layout_option, int rotation); + + public enum CoreError { + ErrorSystemFiles, + ErrorSavestate, + ErrorUnknown, + } + + private static boolean coreErrorAlertResult = false; + private static final Object coreErrorAlertLock = new Object(); + + public static class CoreErrorDialogFragment extends DialogFragment { + static CoreErrorDialogFragment newInstance(String title, String message) { + CoreErrorDialogFragment frag = new CoreErrorDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + return new AlertDialog.Builder(emulationActivity) + .setTitle(title) + .setMessage(message) + .setPositiveButton(R.string.continue_button, (dialog, which) -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }) + .setNegativeButton(R.string.abort_button, (dialog, which) -> { + coreErrorAlertResult = false; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).setOnDismissListener(dialog -> { + coreErrorAlertResult = true; + synchronized (coreErrorAlertLock) { + coreErrorAlertLock.notify(); + } + }).create(); + } + } + + private static void OnCoreErrorImpl(String title, String message) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return; + } + + CoreErrorDialogFragment fragment = CoreErrorDialogFragment.newInstance(title, message); + fragment.show(emulationActivity.getSupportFragmentManager(), "coreError"); + } + + /** + * Handles a core error. + * @return true: continue; false: abort + */ + public static boolean OnCoreError(CoreError error, String details) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + + String title, message; + switch (error) { + case ErrorSystemFiles: { + title = emulationActivity.getString(R.string.system_archive_not_found); + message = emulationActivity.getString(R.string.system_archive_not_found_message, details.isEmpty() ? emulationActivity.getString(R.string.system_archive_general) : details); + break; + } + case ErrorSavestate: { + title = emulationActivity.getString(R.string.save_load_error); + message = details; + break; + } + case ErrorUnknown: { + title = emulationActivity.getString(R.string.fatal_error); + message = emulationActivity.getString(R.string.fatal_error_message); + break; + } + default: { + return true; + } + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(() -> OnCoreErrorImpl(title, message)); + + // Wait for the lock to notify that it is complete. + synchronized (coreErrorAlertLock) { + try { + coreErrorAlertLock.wait(); + } catch (Exception ignored) { + } + } + + return coreErrorAlertResult; + } + + public static boolean isPortraitMode() { + return CitraApplication.getAppContext().getResources().getConfiguration().orientation == + Configuration.ORIENTATION_PORTRAIT; + } + + public static int landscapeScreenLayout() { + return EmulationMenuSettings.getLandscapeScreenLayout(); + } + + public static boolean displayAlertMsg(final String caption, final String text, + final boolean yesNo) { + Log.error("[NativeLibrary] Alert: " + text); + final EmulationActivity emulationActivity = sEmulationActivity.get(); + boolean result = false; + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't do panic alert."); + } else { + // Create object used for waiting. + final Object lock = new Object(); + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setMessage(text); + + // If not yes/no dialog just have one button that dismisses modal, + // otherwise have a yes and no button that sets alertResult accordingly. + if (!yesNo) { + builder + .setCancelable(false) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> + { + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } else { + alertResult = false; + + builder + .setPositiveButton(android.R.string.yes, (dialog, whichButton) -> + { + alertResult = true; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }) + .setNegativeButton(android.R.string.no, (dialog, whichButton) -> + { + alertResult = false; + dialog.dismiss(); + synchronized (lock) { + lock.notify(); + } + }); + } + + // Show the AlertDialog on the main thread. + emulationActivity.runOnUiThread(builder::show); + + // Wait for the lock to notify that it is complete. + synchronized (lock) { + try { + lock.wait(); + } catch (Exception e) { + } + } + + if (yesNo) + result = alertResult; + } + return result; + } + + public static void retryDisplayAlertPrompt() { + if (!alertPromptInProgress) { + return; + } + displayAlertPromptImpl(alertPromptCaption, alertPromptEditText.getText().toString(), alertPromptButtonConfig).show(); + } + + public static String displayAlertPrompt(String caption, String text, int buttonConfig) { + alertPromptCaption = caption; + alertPromptButtonConfig = buttonConfig; + alertPromptInProgress = true; + + // Show the AlertDialog on the main thread + sEmulationActivity.get().runOnUiThread(() -> displayAlertPromptImpl(alertPromptCaption, text, alertPromptButtonConfig).show()); + + // Wait for the lock to notify that it is complete + synchronized (alertPromptLock) { + try { + alertPromptLock.wait(); + } catch (Exception e) { + } + } + alertPromptInProgress = false; + + return alertPromptResult; + } + + public static AlertDialog.Builder displayAlertPromptImpl(String caption, String text, int buttonConfig) { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + alertPromptResult = ""; + alertPromptButton = 0; + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams(ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = CitraApplication.getAppContext().getResources().getDimensionPixelSize(R.dimen.dialog_margin); + + // Set up the input + alertPromptEditText = new EditText(CitraApplication.getAppContext()); + alertPromptEditText.setText(text); + alertPromptEditText.setSingleLine(); + alertPromptEditText.setLayoutParams(params); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(alertPromptEditText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(caption) + .setView(container) + .setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + alertPromptButton = buttonConfig; + alertPromptResult = alertPromptEditText.getText().toString(); + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }) + .setOnDismissListener(dialogInterface -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + + if (buttonConfig > 0) { + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + { + alertPromptResult = ""; + synchronized (alertPromptLock) { + alertPromptLock.notifyAll(); + } + }); + } + + return builder; + } + + public static int alertPromptButton() { + return alertPromptButton; + } + + public static void exitEmulationActivity(int resultCode) { + final int Success = 0; + final int ErrorNotInitialized = 1; + final int ErrorGetLoader = 2; + final int ErrorSystemMode = 3; + final int ErrorLoader = 4; + final int ErrorLoader_ErrorEncrypted = 5; + final int ErrorLoader_ErrorInvalidFormat = 6; + final int ErrorSystemFiles = 7; + final int ErrorVideoCore = 8; + final int ErrorVideoCore_ErrorGenericDrivers = 9; + final int ErrorVideoCore_ErrorBelowGL33 = 10; + final int ShutdownRequested = 11; + final int ErrorUnknown = 12; + + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.warning("[NativeLibrary] EmulationActivity is null, can't exit."); + return; + } + + int captionId = R.string.loader_error_invalid_format; + if (resultCode == ErrorLoader_ErrorEncrypted) { + captionId = R.string.loader_error_encrypted; + } + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(captionId) + .setMessage(Html.fromHtml("Please follow the guides to redump your game cartidges or installed titles.", Html.FROM_HTML_MODE_LEGACY)) + .setPositiveButton(android.R.string.ok, (dialog, whichButton) -> emulationActivity.finish()) + .setOnDismissListener(dialogInterface -> emulationActivity.finish()); + emulationActivity.runOnUiThread(() -> { + AlertDialog alert = builder.create(); + alert.show(); + ((TextView) alert.findViewById(android.R.id.message)).setMovementMethod(LinkMovementMethod.getInstance()); + }); + } + + public static void setEmulationActivity(EmulationActivity emulationActivity) { + Log.verbose("[NativeLibrary] Registering EmulationActivity."); + sEmulationActivity = new WeakReference<>(emulationActivity); + } + + public static void clearEmulationActivity() { + Log.verbose("[NativeLibrary] Unregistering EmulationActivity."); + + sEmulationActivity.clear(); + } + + private static final Object cameraPermissionLock = new Object(); + private static boolean cameraPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_CAMERA = 800; + + public static boolean RequestCameraPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, CAMERA) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{CAMERA}, REQUEST_CODE_NATIVE_CAMERA); + + // Wait until result is returned + synchronized (cameraPermissionLock) { + try { + cameraPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return cameraPermissionGranted; + } + + public static void CameraPermissionResult(boolean granted) { + cameraPermissionGranted = granted; + synchronized (cameraPermissionLock) { + cameraPermissionLock.notify(); + } + } + + private static final Object micPermissionLock = new Object(); + private static boolean micPermissionGranted = false; + public static final int REQUEST_CODE_NATIVE_MIC = 900; + + public static boolean RequestMicPermission() { + final EmulationActivity emulationActivity = sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[NativeLibrary] EmulationActivity not present"); + return false; + } + if (ContextCompat.checkSelfPermission(emulationActivity, RECORD_AUDIO) == PackageManager.PERMISSION_GRANTED) { + // Permission already granted + return true; + } + emulationActivity.requestPermissions(new String[]{RECORD_AUDIO}, REQUEST_CODE_NATIVE_MIC); + + // Wait until result is returned + synchronized (micPermissionLock) { + try { + micPermissionLock.wait(); + } catch (InterruptedException ignored) { + } + } + return micPermissionGranted; + } + + public static void MicPermissionResult(boolean granted) { + micPermissionGranted = granted; + synchronized (micPermissionLock) { + micPermissionLock.notify(); + } + } + + /** + * Logs the Citra version, Android version and, CPU. + */ + public static native void LogDeviceInfo(); + + /** + * Button type for use in onTouchEvent + */ + public static final class ButtonType { + public static final int BUTTON_A = 0; + public static final int BUTTON_B = 1; + public static final int BUTTON_X = 2; + public static final int BUTTON_Y = 3; + public static final int BUTTON_START = 11; + public static final int BUTTON_SELECT = 12; + public static final int BUTTON_HOME = 19; + public static final int BUTTON_ZL = 9; + public static final int BUTTON_ZR = 10; + public static final int DPAD_UP = 14; + public static final int DPAD_DOWN = 16; + public static final int DPAD_LEFT = 13; + public static final int DPAD_RIGHT = 15; + public static final int STICK_LEFT = 5; + public static final int STICK_LEFT_UP = 714; + public static final int STICK_LEFT_DOWN = 715; + public static final int STICK_LEFT_LEFT = 716; + public static final int STICK_LEFT_RIGHT = 717; + public static final int STICK_C = 6; + public static final int STICK_C_UP = 719; + public static final int STICK_C_DOWN = 720; + public static final int STICK_C_LEFT = 771; + public static final int STICK_C_RIGHT = 772; + public static final int TRIGGER_L = 7; + public static final int TRIGGER_R = 8; + public static final int DPAD = 780; + public static final int BUTTON_DEBUG = 781; + public static final int BUTTON_GPIO14 = 782; + } + + /** + * Button states + */ + public static final class ButtonState { + public static final int RELEASED = 0; + public static final int PRESSED = 1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java new file mode 100644 index 000000000..3083286e2 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/CustomFilePickerActivity.java @@ -0,0 +1,38 @@ +package org.citra.citra_emu.activities; + +import android.content.Intent; +import android.os.Environment; + +import androidx.annotation.Nullable; + +import com.nononsenseapps.filepicker.AbstractFilePickerFragment; +import com.nononsenseapps.filepicker.FilePickerActivity; + +import org.citra.citra_emu.fragments.CustomFilePickerFragment; + +import java.io.File; + +public class CustomFilePickerActivity extends FilePickerActivity { + public static final String EXTRA_TITLE = "filepicker.intent.TITLE"; + public static final String EXTRA_EXTENSIONS = "filepicker.intent.EXTENSIONS"; + + @Override + protected AbstractFilePickerFragment getFragment( + @Nullable final String startPath, final int mode, final boolean allowMultiple, + final boolean allowCreateDir, final boolean allowExistingFile, + final boolean singleClick) { + CustomFilePickerFragment fragment = new CustomFilePickerFragment(); + // startPath is allowed to be null. In that case, default folder should be SD-card and not "/" + fragment.setArgs( + startPath != null ? startPath : Environment.getExternalStorageDirectory().getPath(), + mode, allowMultiple, allowCreateDir, allowExistingFile, singleClick); + + Intent intent = getIntent(); + int title = intent == null ? 0 : intent.getIntExtra(EXTRA_TITLE, 0); + fragment.setTitle(title); + String allowedExtensions = intent == null ? "*" : intent.getStringExtra(EXTRA_EXTENSIONS); + fragment.setAllowedExtensions(allowedExtensions); + + return fragment; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java new file mode 100644 index 000000000..47ef0fd23 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/activities/EmulationActivity.java @@ -0,0 +1,755 @@ +package org.citra.citra_emu.activities; + +import android.app.Activity; +import android.content.Intent; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.util.SparseIntArray; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.LayoutInflater; +import android.view.Menu; +import android.view.MenuItem; +import android.view.MotionEvent; +import android.view.View; +import android.widget.CheckBox; +import android.widget.SeekBar; +import android.widget.TextView; + +import androidx.annotation.IntDef; +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.app.NotificationManagerCompat; +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.ui.CheatsActivity; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivity; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.camera.StillImageCameraHelper; +import org.citra.citra_emu.fragments.EmulationFragment; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.ControllerMappingHelper; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.FileBrowserHelper; +import org.citra.citra_emu.utils.FileUtil; +import org.citra.citra_emu.utils.ForegroundService; + +import java.io.File; +import java.io.IOException; +import java.lang.annotation.Retention; +import java.util.Collections; +import java.util.List; + +import static android.Manifest.permission.CAMERA; +import static android.Manifest.permission.RECORD_AUDIO; +import static java.lang.annotation.RetentionPolicy.SOURCE; + +public final class EmulationActivity extends AppCompatActivity { + public static final String EXTRA_SELECTED_GAME = "SelectedGame"; + public static final String EXTRA_SELECTED_TITLE = "SelectedTitle"; + public static final int MENU_ACTION_EDIT_CONTROLS_PLACEMENT = 0; + public static final int MENU_ACTION_TOGGLE_CONTROLS = 1; + public static final int MENU_ACTION_ADJUST_SCALE = 2; + public static final int MENU_ACTION_EXIT = 3; + public static final int MENU_ACTION_SHOW_FPS = 4; + public static final int MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE = 5; + public static final int MENU_ACTION_SCREEN_LAYOUT_PORTRAIT = 6; + public static final int MENU_ACTION_SCREEN_LAYOUT_SINGLE = 7; + public static final int MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE = 8; + public static final int MENU_ACTION_SWAP_SCREENS = 9; + public static final int MENU_ACTION_RESET_OVERLAY = 10; + public static final int MENU_ACTION_SHOW_OVERLAY = 11; + public static final int MENU_ACTION_OPEN_SETTINGS = 12; + public static final int MENU_ACTION_LOAD_AMIIBO = 13; + public static final int MENU_ACTION_REMOVE_AMIIBO = 14; + public static final int MENU_ACTION_JOYSTICK_REL_CENTER = 15; + public static final int MENU_ACTION_DPAD_SLIDE_ENABLE = 16; + public static final int MENU_ACTION_OPEN_CHEATS = 17; + + public static final int REQUEST_SELECT_AMIIBO = 2; + private static final int EMULATION_RUNNING_NOTIFICATION = 0x1000; + private static SparseIntArray buttonsActionsMap = new SparseIntArray(); + + static { + buttonsActionsMap.append(R.id.menu_emulation_edit_layout, + EmulationActivity.MENU_ACTION_EDIT_CONTROLS_PLACEMENT); + buttonsActionsMap.append(R.id.menu_emulation_toggle_controls, + EmulationActivity.MENU_ACTION_TOGGLE_CONTROLS); + buttonsActionsMap + .append(R.id.menu_emulation_adjust_scale, EmulationActivity.MENU_ACTION_ADJUST_SCALE); + buttonsActionsMap.append(R.id.menu_emulation_show_fps, + EmulationActivity.MENU_ACTION_SHOW_FPS); + buttonsActionsMap.append(R.id.menu_screen_layout_landscape, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE); + buttonsActionsMap.append(R.id.menu_screen_layout_portrait, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_PORTRAIT); + buttonsActionsMap.append(R.id.menu_screen_layout_single, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SINGLE); + buttonsActionsMap.append(R.id.menu_screen_layout_sidebyside, + EmulationActivity.MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE); + buttonsActionsMap.append(R.id.menu_emulation_swap_screens, + EmulationActivity.MENU_ACTION_SWAP_SCREENS); + buttonsActionsMap + .append(R.id.menu_emulation_reset_overlay, EmulationActivity.MENU_ACTION_RESET_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_show_overlay, EmulationActivity.MENU_ACTION_SHOW_OVERLAY); + buttonsActionsMap + .append(R.id.menu_emulation_open_settings, EmulationActivity.MENU_ACTION_OPEN_SETTINGS); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_load, EmulationActivity.MENU_ACTION_LOAD_AMIIBO); + buttonsActionsMap + .append(R.id.menu_emulation_amiibo_remove, EmulationActivity.MENU_ACTION_REMOVE_AMIIBO); + buttonsActionsMap.append(R.id.menu_emulation_joystick_rel_center, + EmulationActivity.MENU_ACTION_JOYSTICK_REL_CENTER); + buttonsActionsMap.append(R.id.menu_emulation_dpad_slide_enable, + EmulationActivity.MENU_ACTION_DPAD_SLIDE_ENABLE); + buttonsActionsMap + .append(R.id.menu_emulation_open_cheats, EmulationActivity.MENU_ACTION_OPEN_CHEATS); + } + + private View mDecorView; + private EmulationFragment mEmulationFragment; + private SharedPreferences mPreferences; + private ControllerMappingHelper mControllerMappingHelper; + private Intent foregroundService; + private boolean activityRecreated; + private String mSelectedTitle; + private String mPath; + + public static void launch(FragmentActivity activity, String path, String title) { + Intent launcher = new Intent(activity, EmulationActivity.class); + + launcher.putExtra(EXTRA_SELECTED_GAME, path); + launcher.putExtra(EXTRA_SELECTED_TITLE, title); + activity.startActivity(launcher); + } + + public static void tryDismissRunningNotification(Activity activity) { + NotificationManagerCompat.from(activity).cancel(EMULATION_RUNNING_NOTIFICATION); + } + + @Override + protected void onDestroy() { + stopService(foregroundService); + super.onDestroy(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + if (savedInstanceState == null) { + // Get params we were passed + Intent gameToEmulate = getIntent(); + mPath = gameToEmulate.getStringExtra(EXTRA_SELECTED_GAME); + mSelectedTitle = gameToEmulate.getStringExtra(EXTRA_SELECTED_TITLE); + activityRecreated = false; + } else { + activityRecreated = true; + restoreState(savedInstanceState); + } + + mControllerMappingHelper = new ControllerMappingHelper(); + + // Get a handle to the Window containing the UI. + mDecorView = getWindow().getDecorView(); + mDecorView.setOnSystemUiVisibilityChangeListener(visibility -> + { + if ((visibility & View.SYSTEM_UI_FLAG_FULLSCREEN) == 0) { + // Go back to immersive fullscreen mode in 3s + Handler handler = new Handler(getMainLooper()); + handler.postDelayed(this::enableFullscreenImmersive, 3000 /* 3s */); + } + }); + // Set these options now so that the SurfaceView the game renders into is the right size. + enableFullscreenImmersive(); + + setTheme(R.style.CitraEmulationBase); + + setContentView(R.layout.activity_emulation); + + // Find or create the EmulationFragment + mEmulationFragment = (EmulationFragment) getSupportFragmentManager() + .findFragmentById(R.id.frame_emulation_fragment); + if (mEmulationFragment == null) { + mEmulationFragment = EmulationFragment.newInstance(mPath); + getSupportFragmentManager().beginTransaction() + .add(R.id.frame_emulation_fragment, mEmulationFragment) + .commit(); + } + + setTitle(mSelectedTitle); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(this); + + // Start a foreground service to prevent the app from getting killed in the background + foregroundService = new Intent(EmulationActivity.this, ForegroundService.class); + startForegroundService(foregroundService); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + outState.putString(EXTRA_SELECTED_GAME, mPath); + outState.putString(EXTRA_SELECTED_TITLE, mSelectedTitle); + super.onSaveInstanceState(outState); + } + + protected void restoreState(Bundle savedInstanceState) { + mPath = savedInstanceState.getString(EXTRA_SELECTED_GAME); + mSelectedTitle = savedInstanceState.getString(EXTRA_SELECTED_TITLE); + + // If an alert prompt was in progress when state was restored, retry displaying it + NativeLibrary.retryDisplayAlertPrompt(); + } + + @Override + public void onRestart() { + super.onRestart(); + } + + @Override + public void onBackPressed() { + NativeLibrary.PauseEmulation(); + new AlertDialog.Builder(this) + .setTitle(R.string.emulation_close_game) + .setMessage(R.string.emulation_close_game_message) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> + { + mEmulationFragment.stopEmulation(); + finish(); + }) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> + NativeLibrary.UnPauseEmulation()) + .setOnCancelListener(dialogInterface -> + NativeLibrary.UnPauseEmulation()) + .create() + .show(); + } + + @Override + public void onRequestPermissionsResult(int requestCode, @NonNull String[] permissions, @NonNull int[] grantResults) { + switch (requestCode) { + case NativeLibrary.REQUEST_CODE_NATIVE_CAMERA: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(CAMERA)) { + new AlertDialog.Builder(this) + .setTitle(R.string.camera) + .setMessage(R.string.camera_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.CameraPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + case NativeLibrary.REQUEST_CODE_NATIVE_MIC: + if (grantResults[0] != PackageManager.PERMISSION_GRANTED && + shouldShowRequestPermissionRationale(RECORD_AUDIO)) { + new AlertDialog.Builder(this) + .setTitle(R.string.microphone) + .setMessage(R.string.microphone_permission_needed) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + NativeLibrary.MicPermissionResult(grantResults[0] == PackageManager.PERMISSION_GRANTED); + break; + default: + super.onRequestPermissionsResult(requestCode, permissions, grantResults); + break; + } + } + + private void enableFullscreenImmersive() { + // It would be nice to use IMMERSIVE_STICKY, but that doesn't show the toolbar. + mDecorView.setSystemUiVisibility( + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_IMMERSIVE); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + // Inflate the menu; this adds items to the action bar if it is present. + getMenuInflater().inflate(R.menu.menu_emulation, menu); + + int layoutOptionMenuItem = R.id.menu_screen_layout_landscape; + switch (EmulationMenuSettings.getLandscapeScreenLayout()) { + case EmulationMenuSettings.LayoutOption_SingleScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_single; + break; + case EmulationMenuSettings.LayoutOption_SideScreen: + layoutOptionMenuItem = R.id.menu_screen_layout_sidebyside; + break; + case EmulationMenuSettings.LayoutOption_MobilePortrait: + layoutOptionMenuItem = R.id.menu_screen_layout_portrait; + break; + } + + menu.findItem(layoutOptionMenuItem).setChecked(true); + menu.findItem(R.id.menu_emulation_joystick_rel_center).setChecked(EmulationMenuSettings.getJoystickRelCenter()); + menu.findItem(R.id.menu_emulation_dpad_slide_enable).setChecked(EmulationMenuSettings.getDpadSlideEnable()); + menu.findItem(R.id.menu_emulation_show_fps).setChecked(EmulationMenuSettings.getShowFps()); + menu.findItem(R.id.menu_emulation_swap_screens).setChecked(EmulationMenuSettings.getSwapScreens()); + menu.findItem(R.id.menu_emulation_show_overlay).setChecked(EmulationMenuSettings.getShowOverlay()); + + return true; + } + + private void DisplaySavestateWarning() { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + if (preferences.getBoolean("savestateWarningShown", false)) { + return; + } + + LayoutInflater inflater = mEmulationFragment.requireActivity().getLayoutInflater(); + View view = inflater.inflate(R.layout.dialog_checkbox, null); + CheckBox checkBox = view.findViewById(R.id.checkBox); + + new AlertDialog.Builder(this) + .setTitle(R.string.savestate_warning_title) + .setMessage(R.string.savestate_warning_message) + .setView(view) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + preferences.edit().putBoolean("savestateWarningShown", checkBox.isChecked()).apply(); + }) + .show(); + } + + @Override + public boolean onPrepareOptionsMenu(Menu menu) { + super.onPrepareOptionsMenu(menu); + menu.findItem(R.id.menu_emulation_save_state).setVisible(false); + menu.findItem(R.id.menu_emulation_load_state).setVisible(false); + return true; + } + + @SuppressWarnings("WrongConstant") + @Override + public boolean onOptionsItemSelected(MenuItem item) { + int action = buttonsActionsMap.get(item.getItemId(), -1); + + switch (action) { + // Edit the placement of the controls + case MENU_ACTION_EDIT_CONTROLS_PLACEMENT: + editControlsPlacement(); + break; + + // Enable/Disable specific buttons or the entire input overlay. + case MENU_ACTION_TOGGLE_CONTROLS: + toggleControls(); + break; + + // Adjust the scale of the overlay controls. + case MENU_ACTION_ADJUST_SCALE: + adjustScale(); + break; + + // Toggle the visibility of the Performance stats TextView + case MENU_ACTION_SHOW_FPS: { + final boolean isEnabled = !EmulationMenuSettings.getShowFps(); + EmulationMenuSettings.setShowFps(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.updateShowFpsOverlay(); + break; + } + // Sets the screen layout to Landscape + case MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobileLandscape, item); + break; + + // Sets the screen layout to Portrait + case MENU_ACTION_SCREEN_LAYOUT_PORTRAIT: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_MobilePortrait, item); + break; + + // Sets the screen layout to Single + case MENU_ACTION_SCREEN_LAYOUT_SINGLE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SingleScreen, item); + break; + + // Sets the screen layout to Side by Side + case MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE: + changeScreenOrientation(EmulationMenuSettings.LayoutOption_SideScreen, item); + break; + + // Swap the top and bottom screen locations + case MENU_ACTION_SWAP_SCREENS: { + final boolean isEnabled = !EmulationMenuSettings.getSwapScreens(); + EmulationMenuSettings.setSwapScreens(isEnabled); + item.setChecked(isEnabled); + break; + } + + // Reset overlay placement + case MENU_ACTION_RESET_OVERLAY: + resetOverlay(); + break; + + // Show or hide overlay + case MENU_ACTION_SHOW_OVERLAY: { + final boolean isEnabled = !EmulationMenuSettings.getShowOverlay(); + EmulationMenuSettings.setShowOverlay(isEnabled); + item.setChecked(isEnabled); + + mEmulationFragment.refreshInputOverlay(); + break; + } + + case MENU_ACTION_EXIT: + mEmulationFragment.stopEmulation(); + finish(); + break; + + case MENU_ACTION_OPEN_SETTINGS: + SettingsActivity.launch(this, SettingsFile.FILE_NAME_CONFIG, ""); + break; + + case MENU_ACTION_LOAD_AMIIBO: + FileBrowserHelper.openFilePicker(this, REQUEST_SELECT_AMIIBO, + R.string.select_amiibo, + Collections.singletonList("bin"), false); + break; + + case MENU_ACTION_REMOVE_AMIIBO: + RemoveAmiibo(); + break; + + case MENU_ACTION_JOYSTICK_REL_CENTER: + final boolean isJoystickRelCenterEnabled = !EmulationMenuSettings.getJoystickRelCenter(); + EmulationMenuSettings.setJoystickRelCenter(isJoystickRelCenterEnabled); + item.setChecked(isJoystickRelCenterEnabled); + break; + + case MENU_ACTION_DPAD_SLIDE_ENABLE: + final boolean isDpadSlideEnabled = !EmulationMenuSettings.getDpadSlideEnable(); + EmulationMenuSettings.setDpadSlideEnable(isDpadSlideEnabled); + item.setChecked(isDpadSlideEnabled); + break; + + case MENU_ACTION_OPEN_CHEATS: + CheatsActivity.launch(this); + break; + } + + return true; + } + + private void changeScreenOrientation(int layoutOption, MenuItem item) { + item.setChecked(true); + NativeLibrary.NotifyOrientationChange(layoutOption, getWindowManager().getDefaultDisplay() + .getRotation()); + EmulationMenuSettings.setLandscapeScreenLayout(layoutOption); + } + + private void editControlsPlacement() { + if (mEmulationFragment.isConfiguringControls()) { + mEmulationFragment.stopConfiguringControls(); + } else { + mEmulationFragment.startConfiguringControls(); + } + } + + // Gets button presses + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + int action; + int button = mPreferences.getInt(InputBindingSetting.getInputButtonKey(event.getKeyCode()), event.getKeyCode()); + + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + // Handling the case where the back button is pressed. + if (event.getKeyCode() == KeyEvent.KEYCODE_BACK) { + onBackPressed(); + return true; + } + + // Normal key events. + action = NativeLibrary.ButtonState.PRESSED; + break; + case KeyEvent.ACTION_UP: + action = NativeLibrary.ButtonState.RELEASED; + break; + default: + return false; + } + InputDevice input = event.getDevice(); + + if (input == null) { + // Controller was disconnected + return false; + } + + return NativeLibrary.onGamePadEvent(input.getDescriptor(), button, action); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent result) { + super.onActivityResult(requestCode, resultCode, result); + switch (requestCode) { + case StillImageCameraHelper.REQUEST_CAMERA_FILE_PICKER: + StillImageCameraHelper.OnFilePickerResult(resultCode == RESULT_OK ? result : null); + break; + case REQUEST_SELECT_AMIIBO: + // If the user picked a file, as opposed to just backing out. + if (resultCode == MainActivity.RESULT_OK) { + String[] selectedFiles = FileBrowserHelper.getSelectedFiles(result); + if (selectedFiles == null) + return; + + onAmiiboSelected(selectedFiles[0]); + } + break; + } + } + + private void onAmiiboSelected(String selectedFile) { + File file = new File(selectedFile); + boolean success = false; + try { + byte[] bytes = FileUtil.getBytesFromFile(file); + } catch (IOException e) { + e.printStackTrace(); + } + + if (!success) { + new AlertDialog.Builder(this) + .setTitle(R.string.amiibo_load_error) + .setMessage(R.string.amiibo_load_error_message) + .setPositiveButton(android.R.string.ok, null) + .create() + .show(); + } + } + + private void RemoveAmiibo() { + + } + + private void toggleControls() { + final SharedPreferences.Editor editor = mPreferences.edit(); + boolean[] enabledButtons = new boolean[14]; + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_toggle_controls); + + for (int i = 0; i < enabledButtons.length; i++) { + // Buttons that are disabled by default + boolean defaultValue = true; + switch (i) { + case 6: // ZL + case 7: // ZR + case 12: // C-stick + defaultValue = false; + break; + } + + enabledButtons[i] = mPreferences.getBoolean("buttonToggle" + i, defaultValue); + } + builder.setMultiChoiceItems(R.array.n3dsButtons, enabledButtons, + (dialog, indexSelected, isChecked) -> editor + .putBoolean("buttonToggle" + indexSelected, isChecked)); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + editor.apply(); + + mEmulationFragment.refreshInputOverlay(); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void adjustScale() { + LayoutInflater inflater = LayoutInflater.from(this); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + final SeekBar seekbar = view.findViewById(R.id.seekbar); + final TextView value = view.findViewById(R.id.text_value); + final TextView units = view.findViewById(R.id.text_units); + + seekbar.setMax(150); + seekbar.setProgress(mPreferences.getInt("controlScale", 50)); + seekbar.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener() { + public void onStartTrackingTouch(SeekBar seekBar) { + } + + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + value.setText(String.valueOf(progress + 50)); + } + + public void onStopTrackingTouch(SeekBar seekBar) { + setControlScale(seekbar.getProgress()); + } + }); + + value.setText(String.valueOf(seekbar.getProgress() + 50)); + units.setText("%"); + + AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setTitle(R.string.emulation_control_scale); + builder.setView(view); + final int previousProgress = seekbar.getProgress(); + builder.setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + setControlScale(previousProgress); + }); + builder.setPositiveButton(android.R.string.ok, (dialogInterface, i) -> + { + setControlScale(seekbar.getProgress()); + }); + builder.setNeutralButton(R.string.slider_default, (dialogInterface, i) -> { + setControlScale(50); + }); + + AlertDialog alertDialog = builder.create(); + alertDialog.show(); + } + + private void setControlScale(int scale) { + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", scale); + editor.apply(); + mEmulationFragment.refreshInputOverlay(); + } + + private void resetOverlay() { + new AlertDialog.Builder(this) + .setTitle(getString(R.string.emulation_touch_overlay_reset)) + .setPositiveButton(android.R.string.yes, (dialogInterface, i) -> mEmulationFragment.resetInputOverlay()) + .setNegativeButton(android.R.string.cancel, (dialogInterface, i) -> { + }) + .create() + .show(); + } + + @Override + public boolean dispatchGenericMotionEvent(MotionEvent event) { + if (((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0)) { + return super.dispatchGenericMotionEvent(event); + } + + // Don't attempt to do anything if we are disconnecting a device. + if (event.getActionMasked() == MotionEvent.ACTION_CANCEL) { + return true; + } + + InputDevice input = event.getDevice(); + List motions = input.getMotionRanges(); + + float[] axisValuesCirclePad = {0.0f, 0.0f}; + float[] axisValuesCStick = {0.0f, 0.0f}; + float[] axisValuesDPad = {0.0f, 0.0f}; + boolean isTriggerPressedLMapped = false; + boolean isTriggerPressedRMapped = false; + boolean isTriggerPressedZLMapped = false; + boolean isTriggerPressedZRMapped = false; + boolean isTriggerPressedL = false; + boolean isTriggerPressedR = false; + boolean isTriggerPressedZL = false; + boolean isTriggerPressedZR = false; + + for (InputDevice.MotionRange range : motions) { + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = mControllerMappingHelper.scaleAxis(input, axis, origValue); + int nextMapping = mPreferences.getInt(InputBindingSetting.getInputAxisButtonKey(axis), -1); + int guestOrientation = mPreferences.getInt(InputBindingSetting.getInputAxisOrientationKey(axis), -1); + + if (nextMapping == -1 || guestOrientation == -1) { + // Axis is unmapped + continue; + } + + if ((value > 0.f && value < 0.1f) || (value < 0.f && value > -0.1f)) { + // Skip joystick wobble + value = 0.f; + } + + if (nextMapping == NativeLibrary.ButtonType.STICK_LEFT) { + axisValuesCirclePad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.STICK_C) { + axisValuesCStick[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.DPAD) { + axisValuesDPad[guestOrientation] = value; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_L) { + isTriggerPressedLMapped = true; + isTriggerPressedL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.TRIGGER_R) { + isTriggerPressedRMapped = true; + isTriggerPressedR = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZL) { + isTriggerPressedZLMapped = true; + isTriggerPressedZL = value != 0.f; + } else if (nextMapping == NativeLibrary.ButtonType.BUTTON_ZR) { + isTriggerPressedZRMapped = true; + isTriggerPressedZR = value != 0.f; + } + } + + // Circle-Pad and C-Stick status + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_LEFT, axisValuesCirclePad[0], axisValuesCirclePad[1]); + NativeLibrary.onGamePadMoveEvent(input.getDescriptor(), NativeLibrary.ButtonType.STICK_C, axisValuesCStick[0], axisValuesCStick[1]); + + // Triggers L/R and ZL/ZR + if (isTriggerPressedLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_L, isTriggerPressedL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.TRIGGER_R, isTriggerPressedR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZLMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZL, isTriggerPressedZL ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + if (isTriggerPressedZRMapped) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.BUTTON_ZR, isTriggerPressedZR ? NativeLibrary.ButtonState.PRESSED : NativeLibrary.ButtonState.RELEASED); + } + + // Work-around to allow D-pad axis to be bound to emulated buttons + if (axisValuesDPad[0] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[0] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_LEFT, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_RIGHT, NativeLibrary.ButtonState.PRESSED); + } + if (axisValuesDPad[1] == 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] < 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.PRESSED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.RELEASED); + } + if (axisValuesDPad[1] > 0.f) { + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_UP, NativeLibrary.ButtonState.RELEASED); + NativeLibrary.onGamePadEvent(NativeLibrary.TouchScreenDevice, NativeLibrary.ButtonType.DPAD_DOWN, NativeLibrary.ButtonState.PRESSED); + } + + return true; + } + + public boolean isActivityRecreated() { + return activityRecreated; + } + + @Retention(SOURCE) + @IntDef({MENU_ACTION_EDIT_CONTROLS_PLACEMENT, MENU_ACTION_TOGGLE_CONTROLS, MENU_ACTION_ADJUST_SCALE, + MENU_ACTION_EXIT, MENU_ACTION_SHOW_FPS, MENU_ACTION_SCREEN_LAYOUT_LANDSCAPE, + MENU_ACTION_SCREEN_LAYOUT_PORTRAIT, MENU_ACTION_SCREEN_LAYOUT_SINGLE, MENU_ACTION_SCREEN_LAYOUT_SIDEBYSIDE, + MENU_ACTION_SWAP_SCREENS, MENU_ACTION_RESET_OVERLAY, MENU_ACTION_SHOW_OVERLAY, MENU_ACTION_OPEN_SETTINGS}) + public @interface MenuAction { + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java new file mode 100644 index 000000000..bc791638a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/adapters/GameAdapter.java @@ -0,0 +1,247 @@ +package org.citra.citra_emu.adapters; + +import android.database.Cursor; +import android.database.DataSetObserver; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.os.Build; +import android.os.SystemClock; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.RequiresApi; +import androidx.core.content.ContextCompat; +import androidx.fragment.app.FragmentActivity; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.model.GameDatabase; +import org.citra.citra_emu.ui.DividerItemDecoration; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.PicassoUtils; +import org.citra.citra_emu.viewholders.GameViewHolder; + +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.stream.Stream; + +/** + * This adapter gets its information from a database Cursor. This fact, paired with the usage of + * ContentProviders and Loaders, allows for efficient display of a limited view into a (possibly) + * large dataset. + */ +public final class GameAdapter extends RecyclerView.Adapter implements + View.OnClickListener { + private Cursor mCursor; + private GameDataSetObserver mObserver; + + private boolean mDatasetValid; + private long mLastClickTime = 0; + + /** + * Initializes the adapter's observer, which watches for changes to the dataset. The adapter will + * display no data until a Cursor is supplied by a CursorLoader. + */ + public GameAdapter() { + mDatasetValid = false; + mObserver = new GameDataSetObserver(); + } + + /** + * Called by the LayoutManager when it is necessary to create a new view. + * + * @param parent The RecyclerView (I think?) the created view will be thrown into. + * @param viewType Not used here, but useful when more than one type of child will be used in the RecyclerView. + * @return The created ViewHolder with references to all the child view's members. + */ + @Override + public GameViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + // Create a new view. + View gameCard = LayoutInflater.from(parent.getContext()) + .inflate(R.layout.card_game, parent, false); + + gameCard.setOnClickListener(this); + + // Use that view to create a ViewHolder. + return new GameViewHolder(gameCard); + } + + /** + * Called by the LayoutManager when a new view is not necessary because we can recycle + * an existing one (for example, if a view just scrolled onto the screen from the bottom, we + * can use the view that just scrolled off the top instead of inflating a new one.) + * + * @param holder A ViewHolder representing the view we're recycling. + * @param position The position of the 'new' view in the dataset. + */ + @RequiresApi(api = Build.VERSION_CODES.O) + @Override + public void onBindViewHolder(@NonNull GameViewHolder holder, int position) { + if (mDatasetValid) { + if (mCursor.moveToPosition(position)) { + PicassoUtils.loadGameIcon(holder.imageIcon, + mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + + holder.textGameTitle.setText(mCursor.getString(GameDatabase.GAME_COLUMN_TITLE).replaceAll("[\\t\\n\\r]+", " ")); + holder.textCompany.setText(mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + + final Path gamePath = Paths.get(mCursor.getString(GameDatabase.GAME_COLUMN_PATH)); + holder.textFileName.setText(gamePath.getFileName().toString()); + + // TODO These shouldn't be necessary once the move to a DB-based model is complete. + holder.gameId = mCursor.getString(GameDatabase.GAME_COLUMN_GAME_ID); + holder.path = mCursor.getString(GameDatabase.GAME_COLUMN_PATH); + holder.title = mCursor.getString(GameDatabase.GAME_COLUMN_TITLE); + holder.description = mCursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION); + holder.regions = mCursor.getString(GameDatabase.GAME_COLUMN_REGIONS); + holder.company = mCursor.getString(GameDatabase.GAME_COLUMN_COMPANY); + + final int backgroundColorId = isValidGame(holder.path) ? R.color.card_view_background : R.color.card_view_disabled; + View itemView = holder.getItemView(); + itemView.setBackgroundColor(ContextCompat.getColor(itemView.getContext(), backgroundColorId)); + } else { + Log.error("[GameAdapter] Can't bind view; Cursor is not valid."); + } + } else { + Log.error("[GameAdapter] Can't bind view; dataset is not valid."); + } + } + + /** + * Called by the LayoutManager to find out how much data we have. + * + * @return Size of the dataset. + */ + @Override + public int getItemCount() { + if (mDatasetValid && mCursor != null) { + return mCursor.getCount(); + } + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Return the contents of the _id column for a given row. + * + * @param position The row for which Android wants an ID. + * @return A valid ID from the database, or 0 if not available. + */ + @Override + public long getItemId(int position) { + if (mDatasetValid && mCursor != null) { + if (mCursor.moveToPosition(position)) { + return mCursor.getLong(GameDatabase.COLUMN_DB_ID); + } + } + + Log.error("[GameAdapter] Dataset is not valid."); + return 0; + } + + /** + * Tell Android whether or not each item in the dataset has a stable identifier. + * Which it does, because it's a database, so always tell Android 'true'. + * + * @param hasStableIds ignored. + */ + @Override + public void setHasStableIds(boolean hasStableIds) { + super.setHasStableIds(true); + } + + /** + * When a load is finished, call this to replace the existing data with the newly-loaded + * data. + * + * @param cursor The newly-loaded Cursor. + */ + public void swapCursor(Cursor cursor) { + // Sanity check. + if (cursor == mCursor) { + return; + } + + // Before getting rid of the old cursor, disassociate it from the Observer. + final Cursor oldCursor = mCursor; + if (oldCursor != null && mObserver != null) { + oldCursor.unregisterDataSetObserver(mObserver); + } + + mCursor = cursor; + if (mCursor != null) { + // Attempt to associate the new Cursor with the Observer. + if (mObserver != null) { + mCursor.registerDataSetObserver(mObserver); + } + + mDatasetValid = true; + } else { + mDatasetValid = false; + } + + notifyDataSetChanged(); + } + + /** + * Launches the game that was clicked on. + * + * @param view The card representing the game the user wants to play. + */ + @Override + public void onClick(View view) { + // Double-click prevention, using threshold of 1000 ms + if (SystemClock.elapsedRealtime() - mLastClickTime < 1000) { + return; + } + mLastClickTime = SystemClock.elapsedRealtime(); + + GameViewHolder holder = (GameViewHolder) view.getTag(); + + EmulationActivity.launch((FragmentActivity) view.getContext(), holder.path, holder.title); + } + + public static class SpacesItemDecoration extends DividerItemDecoration { + private int space; + + public SpacesItemDecoration(Drawable divider, int space) { + super(divider); + this.space = space; + } + + @Override + public void getItemOffsets(Rect outRect, @NonNull View view, @NonNull RecyclerView parent, + @NonNull RecyclerView.State state) { + outRect.left = 0; + outRect.right = 0; + outRect.bottom = space; + outRect.top = 0; + } + } + + private boolean isValidGame(String path) { + return Stream.of( + ".rar", ".zip", ".7z", ".torrent", ".tar", ".gz").noneMatch(suffix -> path.toLowerCase().endsWith(suffix)); + } + + private final class GameDataSetObserver extends DataSetObserver { + @Override + public void onChanged() { + super.onChanged(); + + mDatasetValid = true; + notifyDataSetChanged(); + } + + @Override + public void onInvalidated() { + super.onInvalidated(); + + mDatasetValid = false; + notifyDataSetChanged(); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java new file mode 100644 index 000000000..3586a9b34 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/MiiSelector.java @@ -0,0 +1,122 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets; + +import android.app.Activity; +import android.app.Dialog; +import android.os.Bundle; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Objects; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +public final class MiiSelector { + public static class MiiSelectorConfig implements java.io.Serializable { + public boolean enable_cancel_button; + public String title; + public long initially_selected_mii_index; + // List of Miis to display + public String[] mii_names; + } + + public static class MiiSelectorData { + public long return_code; + public int index; + + private MiiSelectorData(long return_code, int index) { + this.return_code = return_code; + this.index = index; + } + } + + public static class MiiSelectorDialogFragment extends DialogFragment { + static MiiSelectorDialogFragment newInstance(MiiSelectorConfig config) { + MiiSelectorDialogFragment frag = new MiiSelectorDialogFragment(); + Bundle args = new Bundle(); + args.putSerializable("config", config); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + MiiSelectorConfig config = + Objects.requireNonNull((MiiSelectorConfig) Objects.requireNonNull(getArguments()) + .getSerializable("config")); + + // Note: we intentionally leave out the Standard Mii in the native code so that + // the string can get translated + ArrayList list = new ArrayList<>(); + list.add(emulationActivity.getString(R.string.standard_mii)); + list.addAll(Arrays.asList(config.mii_names)); + + final int initialIndex = config.initially_selected_mii_index < list.size() + ? (int) config.initially_selected_mii_index + : 0; + data.index = initialIndex; + AlertDialog.Builder builder = + new AlertDialog.Builder(emulationActivity) + .setTitle(config.title.isEmpty() + ? emulationActivity.getString(R.string.mii_selector) + : config.title) + .setSingleChoiceItems(list.toArray(new String[]{}), initialIndex, + (dialog, which) -> { + data.index = which; + }) + .setPositiveButton(android.R.string.ok, (dialog, which) -> { + data.return_code = 0; + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + if (config.enable_cancel_button) { + builder.setNegativeButton(android.R.string.cancel, (dialog, which) -> { + data.return_code = 1; + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + setCancelable(false); + return builder.create(); + } + } + + private static MiiSelectorData data; + private static final Object finishLock = new Object(); + + private static void ExecuteImpl(MiiSelectorConfig config) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + data = new MiiSelectorData(0, 0); + + MiiSelectorDialogFragment fragment = MiiSelectorDialogFragment.newInstance(config); + fragment.show(emulationActivity.getSupportFragmentManager(), "mii_selector"); + } + + public static MiiSelectorData Execute(MiiSelectorConfig config) { + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + + return data; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java new file mode 100644 index 000000000..7be5f6d97 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/applets/SoftwareKeyboard.java @@ -0,0 +1,264 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.applets; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.text.InputFilter; +import android.text.Spanned; +import android.view.ViewGroup; +import android.widget.EditText; +import android.widget.FrameLayout; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.Objects; + +public final class SoftwareKeyboard { + /// Corresponds to Frontend::ButtonConfig + private interface ButtonConfig { + int Single = 0; /// Ok button + int Dual = 1; /// Cancel | Ok buttons + int Triple = 2; /// Cancel | I Forgot | Ok buttons + int None = 3; /// No button (returned by swkbdInputText in special cases) + } + + /// Corresponds to Frontend::ValidationError + public enum ValidationError { + None, + // Button Selection + ButtonOutOfRange, + // Configured Filters + MaxDigitsExceeded, + AtSignNotAllowed, + PercentNotAllowed, + BackslashNotAllowed, + ProfanityNotAllowed, + CallbackFailed, + // Allowed Input Type + FixedLengthRequired, + MaxLengthExceeded, + BlankInputNotAllowed, + EmptyInputNotAllowed, + } + + public static class KeyboardConfig implements java.io.Serializable { + public int button_config; + public int max_text_length; + public boolean multiline_mode; /// True if the keyboard accepts multiple lines of input + public String hint_text; /// Displayed in the field as a hint before + @Nullable + public String[] button_text; /// Contains the button text that the caller provides + } + + /// Corresponds to Frontend::KeyboardData + public static class KeyboardData { + public int button; + public String text; + + private KeyboardData(int button, String text) { + this.button = button; + this.text = text; + } + } + + private static class Filter implements InputFilter { + @Override + public CharSequence filter(CharSequence source, int start, int end, Spanned dest, + int dstart, int dend) { + String text = new StringBuilder(dest) + .replace(dstart, dend, source.subSequence(start, end).toString()) + .toString(); + if (ValidateFilters(text) == ValidationError.None) { + return null; // Accept replacement + } + return dest.subSequence(dstart, dend); // Request the subsequence to be unchanged + } + } + + public static class KeyboardDialogFragment extends DialogFragment { + static KeyboardDialogFragment newInstance(KeyboardConfig config) { + KeyboardDialogFragment frag = new KeyboardDialogFragment(); + Bundle args = new Bundle(); + args.putSerializable("config", config); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = getActivity(); + assert emulationActivity != null; + + FrameLayout.LayoutParams params = new FrameLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT); + params.leftMargin = params.rightMargin = + CitraApplication.getAppContext().getResources().getDimensionPixelSize( + R.dimen.dialog_margin); + + KeyboardConfig config = Objects.requireNonNull( + (KeyboardConfig) Objects.requireNonNull(getArguments()).getSerializable("config")); + + // Set up the input + EditText editText = new EditText(CitraApplication.getAppContext()); + editText.setHint(config.hint_text); + editText.setSingleLine(!config.multiline_mode); + editText.setLayoutParams(params); + editText.setFilters(new InputFilter[]{ + new Filter(), new InputFilter.LengthFilter(config.max_text_length)}); + + FrameLayout container = new FrameLayout(emulationActivity); + container.addView(editText); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setView(container); + setCancelable(false); + + switch (config.button_config) { + case ButtonConfig.Triple: { + final String text = config.button_text[1].isEmpty() + ? emulationActivity.getString(R.string.i_forgot) + : config.button_text[1]; + builder.setNeutralButton(text, null); + } + // fallthrough + case ButtonConfig.Dual: { + final String text = config.button_text[0].isEmpty() + ? emulationActivity.getString(android.R.string.cancel) + : config.button_text[0]; + builder.setNegativeButton(text, null); + } + // fallthrough + case ButtonConfig.Single: { + final String text = config.button_text[2].isEmpty() + ? emulationActivity.getString(android.R.string.ok) + : config.button_text[2]; + builder.setPositiveButton(text, null); + break; + } + } + + final AlertDialog dialog = builder.create(); + dialog.create(); + if (dialog.getButton(DialogInterface.BUTTON_POSITIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_POSITIVE).setOnClickListener((view) -> { + data.button = config.button_config; + data.text = editText.getText().toString(); + final ValidationError error = ValidateInput(data.text); + if (error != ValidationError.None) { + HandleValidationError(config, error); + return; + } + + dialog.dismiss(); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEUTRAL) != null) { + dialog.getButton(DialogInterface.BUTTON_NEUTRAL).setOnClickListener((view) -> { + data.button = 1; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + if (dialog.getButton(DialogInterface.BUTTON_NEGATIVE) != null) { + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((view) -> { + data.button = 0; + dialog.dismiss(); + synchronized (finishLock) { + finishLock.notifyAll(); + } + }); + } + + return dialog; + } + } + + private static KeyboardData data; + private static final Object finishLock = new Object(); + + private static void ExecuteImpl(KeyboardConfig config) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + data = new KeyboardData(0, ""); + + KeyboardDialogFragment fragment = KeyboardDialogFragment.newInstance(config); + fragment.show(emulationActivity.getSupportFragmentManager(), "keyboard"); + } + + private static void HandleValidationError(KeyboardConfig config, ValidationError error) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + String message = ""; + switch (error) { + case FixedLengthRequired: + message = + emulationActivity.getString(R.string.fixed_length_required, config.max_text_length); + break; + case MaxLengthExceeded: + message = + emulationActivity.getString(R.string.max_length_exceeded, config.max_text_length); + break; + case BlankInputNotAllowed: + message = emulationActivity.getString(R.string.blank_input_not_allowed); + break; + case EmptyInputNotAllowed: + message = emulationActivity.getString(R.string.empty_input_not_allowed); + break; + } + + new AlertDialog.Builder(emulationActivity) + .setTitle(R.string.software_keyboard) + .setMessage(message) + .setPositiveButton(android.R.string.ok, null) + .show(); + } + + public static KeyboardData Execute(KeyboardConfig config) { + if (config.button_config == ButtonConfig.None) { + Log.error("Unexpected button config None"); + return new KeyboardData(0, ""); + } + + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> ExecuteImpl(config)); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + + return data; + } + + public static void ShowError(String error) { + NativeLibrary.displayAlertMsg( + CitraApplication.getAppContext().getResources().getString(R.string.software_keyboard), + error, false); + } + + private static native ValidationError ValidateFilters(String text); + + private static native ValidationError ValidateInput(String text); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java new file mode 100644 index 000000000..701cb0710 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/camera/StillImageCameraHelper.java @@ -0,0 +1,65 @@ +// Copyright 2020 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.camera; + +import android.content.Intent; +import android.graphics.Bitmap; +import android.provider.MediaStore; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.PicassoUtils; + +import androidx.annotation.Nullable; + +// Used in native code. +public final class StillImageCameraHelper { + public static final int REQUEST_CAMERA_FILE_PICKER = 1; + private static final Object filePickerLock = new Object(); + private static @Nullable + String filePickerPath; + + // Opens file picker for camera. + public static @Nullable + String OpenFilePicker() { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + + // At this point, we are assuming that we already have permissions as they are + // needed to launch a game + emulationActivity.runOnUiThread(() -> { + Intent intent = new Intent(Intent.ACTION_PICK); + intent.setDataAndType(MediaStore.Images.Media.INTERNAL_CONTENT_URI, "image/*"); + emulationActivity.startActivityForResult( + Intent.createChooser(intent, + emulationActivity.getString(R.string.camera_select_image)), + REQUEST_CAMERA_FILE_PICKER); + }); + + synchronized (filePickerLock) { + try { + filePickerLock.wait(); + } catch (InterruptedException ignored) { + } + } + + return filePickerPath; + } + + // Called from EmulationActivity. + public static void OnFilePickerResult(Intent result) { + filePickerPath = result == null ? null : result.getDataString(); + + synchronized (filePickerLock) { + filePickerLock.notifyAll(); + } + } + + // Blocking call. Load image from file and crop/resize it to fit in width x height. + @Nullable + public static Bitmap LoadImageFromFile(String uri, int width, int height) { + return PicassoUtils.LoadBitmapFromFile(uri, width, height); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java new file mode 100644 index 000000000..0f10f1858 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/dialogs/MotionAlertDialog.java @@ -0,0 +1,140 @@ +package org.citra.citra_emu.dialogs; + +import android.content.Context; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.MotionEvent; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; + +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.List; + +/** + * {@link AlertDialog} derivative that listens for + * motion events from controllers and joysticks. + */ +public final class MotionAlertDialog extends AlertDialog { + // The selected input preference + private final InputBindingSetting setting; + private final ArrayList mPreviousValues = new ArrayList<>(); + private int mPrevDeviceId = 0; + private boolean mWaitingForEvent = true; + + /** + * Constructor + * + * @param context The current {@link Context}. + * @param setting The Preference to show this dialog for. + */ + public MotionAlertDialog(Context context, InputBindingSetting setting) { + super(context); + + this.setting = setting; + } + + public boolean onKeyEvent(int keyCode, KeyEvent event) { + Log.debug("[MotionAlertDialog] Received key event: " + event.getAction()); + switch (event.getAction()) { + case KeyEvent.ACTION_UP: + setting.onKeyInput(event); + dismiss(); + // Even if we ignore the key, we still consume it. Thus return true regardless. + return true; + + default: + return false; + } + } + + @Override + public boolean onKeyLongPress(int keyCode, @NonNull KeyEvent event) { + return super.onKeyLongPress(keyCode, event); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + // Handle this key if we care about it, otherwise pass it down the framework + return onKeyEvent(event.getKeyCode(), event) || super.dispatchKeyEvent(event); + } + + @Override + public boolean dispatchGenericMotionEvent(@NonNull MotionEvent event) { + // Handle this event if we care about it, otherwise pass it down the framework + return onMotionEvent(event) || super.dispatchGenericMotionEvent(event); + } + + private boolean onMotionEvent(MotionEvent event) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == 0) + return false; + if (event.getAction() != MotionEvent.ACTION_MOVE) + return false; + + InputDevice input = event.getDevice(); + + List motionRanges = input.getMotionRanges(); + + if (input.getId() != mPrevDeviceId) { + mPreviousValues.clear(); + } + mPrevDeviceId = input.getId(); + boolean firstEvent = mPreviousValues.isEmpty(); + + int numMovedAxis = 0; + float axisMoveValue = 0.0f; + InputDevice.MotionRange lastMovedRange = null; + char lastMovedDir = '?'; + if (mWaitingForEvent) { + for (int i = 0; i < motionRanges.size(); i++) { + InputDevice.MotionRange range = motionRanges.get(i); + int axis = range.getAxis(); + float origValue = event.getAxisValue(axis); + float value = origValue;//ControllerMappingHelper.scaleAxis(input, axis, origValue); + if (firstEvent) { + mPreviousValues.add(value); + } else { + float previousValue = mPreviousValues.get(i); + + // Only handle the axes that are not neutral (more than 0.5) + // but ignore any axis that has a constant value (e.g. always 1) + if (Math.abs(value) > 0.5f && value != previousValue) { + // It is common to have multiple axes with the same physical input. For example, + // shoulder butters are provided as both AXIS_LTRIGGER and AXIS_BRAKE. + // To handle this, we ignore an axis motion that's the exact same as a motion + // we already saw. This way, we ignore axes with two names, but catch the case + // where a joystick is moved in two directions. + // ref: bottom of https://developer.android.com/training/game-controllers/controller-input.html + if (value != axisMoveValue) { + axisMoveValue = value; + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = value < 0.0f ? '-' : '+'; + } + } + // Special case for d-pads (axis value jumps between 0 and 1 without any values + // in between). Without this, the user would need to press the d-pad twice + // due to the first press being caught by the "if (firstEvent)" case further up. + else if (Math.abs(value) < 0.25f && Math.abs(previousValue) > 0.75f) { + numMovedAxis++; + lastMovedRange = range; + lastMovedDir = previousValue < 0.0f ? '-' : '+'; + } + } + + mPreviousValues.set(i, value); + } + + // If only one axis moved, that's the winner. + if (numMovedAxis == 1) { + mWaitingForEvent = false; + setting.onMotionInput(input, lastMovedRange, lastMovedDir); + dismiss(); + } + } + return true; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java new file mode 100644 index 000000000..d6d14cc5f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/disk_shader_cache/DiskShaderCacheProgress.java @@ -0,0 +1,138 @@ +// Copyright 2021 Citra Emulator Project +// Licensed under GPLv2 or any later version +// Refer to the license.txt file included. + +package org.citra.citra_emu.disk_shader_cache; + +import android.app.Activity; +import android.app.Dialog; +import android.content.DialogInterface; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.widget.ProgressBar; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.DialogFragment; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.Objects; + +public class DiskShaderCacheProgress { + + // Equivalent to VideoCore::LoadCallbackStage + public enum LoadCallbackStage { + Prepare, + Decompile, + Build, + Complete, + } + + private static final Object finishLock = new Object(); + private static ProgressDialogFragment fragment; + + public static class ProgressDialogFragment extends DialogFragment { + ProgressBar progressBar; + TextView progressText; + AlertDialog dialog; + + static ProgressDialogFragment newInstance(String title, String message) { + ProgressDialogFragment frag = new ProgressDialogFragment(); + Bundle args = new Bundle(); + args.putString("title", title); + args.putString("message", message); + frag.setArguments(args); + return frag; + } + + @NonNull + @Override + public Dialog onCreateDialog(Bundle savedInstanceState) { + final Activity emulationActivity = Objects.requireNonNull(getActivity()); + + final String title = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("title")); + final String message = Objects.requireNonNull(Objects.requireNonNull(getArguments()).getString("message")); + + LayoutInflater inflater = LayoutInflater.from(emulationActivity); + View view = inflater.inflate(R.layout.dialog_progress_bar, null); + + progressBar = view.findViewById(R.id.progress_bar); + progressText = view.findViewById(R.id.progress_text); + progressText.setText(""); + + setCancelable(false); + setRetainInstance(true); + + AlertDialog.Builder builder = new AlertDialog.Builder(emulationActivity); + builder.setTitle(title); + builder.setMessage(message); + builder.setView(view); + builder.setNegativeButton(android.R.string.cancel, null); + + dialog = builder.create(); + dialog.create(); + + dialog.getButton(DialogInterface.BUTTON_NEGATIVE).setOnClickListener((v) -> emulationActivity.onBackPressed()); + + synchronized (finishLock) { + finishLock.notifyAll(); + } + + return dialog; + } + + private void onUpdateProgress(String msg, int progress, int max) { + Objects.requireNonNull(getActivity()).runOnUiThread(() -> { + progressBar.setProgress(progress); + progressBar.setMax(max); + progressText.setText(String.format("%d/%d", progress, max)); + dialog.setMessage(msg); + }); + } + } + + private static void prepareDialog() { + NativeLibrary.sEmulationActivity.get().runOnUiThread(() -> { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + fragment = ProgressDialogFragment.newInstance(emulationActivity.getString(R.string.loading), emulationActivity.getString(R.string.preparing_shaders)); + fragment.show(emulationActivity.getSupportFragmentManager(), "diskShaders"); + }); + + synchronized (finishLock) { + try { + finishLock.wait(); + } catch (Exception ignored) { + } + } + } + + public static void loadProgress(LoadCallbackStage stage, int progress, int max) { + final EmulationActivity emulationActivity = NativeLibrary.sEmulationActivity.get(); + if (emulationActivity == null) { + Log.error("[DiskShaderCacheProgress] EmulationActivity not present"); + return; + } + + switch (stage) { + case Prepare: + prepareDialog(); + break; + case Decompile: + fragment.onUpdateProgress(emulationActivity.getString(R.string.preparing_shaders), progress, max); + break; + case Build: + fragment.onUpdateProgress(emulationActivity.getString(R.string.building_shaders), progress, max); + break; + case Complete: + // Workaround for when dialog is dismissed when the app is in the background + fragment.dismissAllowingStateLoss(); + break; + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java new file mode 100644 index 000000000..93b026364 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/Cheat.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.cheats.model; + +import androidx.annotation.Keep; +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; + +public class Cheat { + @Keep + private final long mPointer; + + private Runnable mEnabledChangedCallback = null; + + @Keep + private Cheat(long pointer) { + mPointer = pointer; + } + + @Override + protected native void finalize(); + + @NonNull + public native String getName(); + + @NonNull + public native String getNotes(); + + @NonNull + public native String getCode(); + + public native boolean getEnabled(); + + public void setEnabled(boolean enabled) { + setEnabledImpl(enabled); + onEnabledChanged(); + } + + private native void setEnabledImpl(boolean enabled); + + public void setEnabledChangedCallback(@Nullable Runnable callback) { + mEnabledChangedCallback = callback; + } + + private void onEnabledChanged() { + if (mEnabledChangedCallback != null) { + mEnabledChangedCallback.run(); + } + } + + /** + * If the code is valid, returns 0. Otherwise, returns the 1-based index + * for the line containing the error. + */ + public static native int isValidGatewayCode(@NonNull String code); + + public static native Cheat createGatewayCode(@NonNull String name, @NonNull String notes, + @NonNull String code); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java new file mode 100644 index 000000000..5748162bb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatEngine.java @@ -0,0 +1,13 @@ +package org.citra.citra_emu.features.cheats.model; + +public class CheatEngine { + public static native Cheat[] getCheats(); + + public static native void addCheat(Cheat cheat); + + public static native void removeCheat(int index); + + public static native void updateCheat(int index, Cheat newCheat); + + public static native void saveCheatFile(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java new file mode 100644 index 000000000..66f4202d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/model/CheatsViewModel.java @@ -0,0 +1,177 @@ +package org.citra.citra_emu.features.cheats.model; + +import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; +import androidx.lifecycle.ViewModel; + +public class CheatsViewModel extends ViewModel { + private int mSelectedCheatPosition = -1; + private final MutableLiveData mSelectedCheat = new MutableLiveData<>(null); + private final MutableLiveData mIsAdding = new MutableLiveData<>(false); + private final MutableLiveData mIsEditing = new MutableLiveData<>(false); + + private final MutableLiveData mCheatAddedEvent = new MutableLiveData<>(null); + private final MutableLiveData mCheatChangedEvent = new MutableLiveData<>(null); + private final MutableLiveData mCheatDeletedEvent = new MutableLiveData<>(null); + private final MutableLiveData mOpenDetailsViewEvent = new MutableLiveData<>(false); + + private Cheat[] mCheats; + private boolean mCheatsNeedSaving = false; + + public void load() { + mCheats = CheatEngine.getCheats(); + + for (int i = 0; i < mCheats.length; i++) { + int position = i; + mCheats[i].setEnabledChangedCallback(() -> { + mCheatsNeedSaving = true; + notifyCheatUpdated(position); + }); + } + } + + public void saveIfNeeded() { + if (mCheatsNeedSaving) { + CheatEngine.saveCheatFile(); + mCheatsNeedSaving = false; + } + } + + public Cheat[] getCheats() { + return mCheats; + } + + public LiveData getSelectedCheat() { + return mSelectedCheat; + } + + public void setSelectedCheat(Cheat cheat, int position) { + if (mIsEditing.getValue()) { + setIsEditing(false); + } + + mSelectedCheat.setValue(cheat); + mSelectedCheatPosition = position; + } + + public LiveData getIsAdding() { + return mIsAdding; + } + + public LiveData getIsEditing() { + return mIsEditing; + } + + public void setIsEditing(boolean isEditing) { + mIsEditing.setValue(isEditing); + + if (mIsAdding.getValue() && !isEditing) { + mIsAdding.setValue(false); + setSelectedCheat(null, -1); + } + } + + /** + * When a cheat is added, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatAddedEvent() { + return mCheatAddedEvent; + } + + private void notifyCheatAdded(int position) { + mCheatAddedEvent.setValue(position); + mCheatAddedEvent.setValue(null); + } + + public void startAddingCheat() { + mSelectedCheat.setValue(null); + mSelectedCheatPosition = -1; + + mIsAdding.setValue(true); + mIsEditing.setValue(true); + } + + public void finishAddingCheat(Cheat cheat) { + if (!mIsAdding.getValue()) { + throw new IllegalStateException(); + } + + mIsAdding.setValue(false); + mIsEditing.setValue(false); + + int position = mCheats.length; + + CheatEngine.addCheat(cheat); + + mCheatsNeedSaving = true; + load(); + + notifyCheatAdded(position); + setSelectedCheat(mCheats[position], position); + } + + /** + * When a cheat is edited, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatUpdatedEvent() { + return mCheatChangedEvent; + } + + /** + * Notifies that an edit has been made to the contents of the cheat at the given position. + */ + private void notifyCheatUpdated(int position) { + mCheatChangedEvent.setValue(position); + mCheatChangedEvent.setValue(null); + } + + public void updateSelectedCheat(Cheat newCheat) { + CheatEngine.updateCheat(mSelectedCheatPosition, newCheat); + + mCheatsNeedSaving = true; + load(); + + notifyCheatUpdated(mSelectedCheatPosition); + setSelectedCheat(mCheats[mSelectedCheatPosition], mSelectedCheatPosition); + } + + /** + * When a cheat is deleted, the integer stored in the returned LiveData + * changes to the position of that cheat, then changes back to null. + */ + public LiveData getCheatDeletedEvent() { + return mCheatDeletedEvent; + } + + /** + * Notifies that the cheat at the given position has been deleted. + */ + private void notifyCheatDeleted(int position) { + mCheatDeletedEvent.setValue(position); + mCheatDeletedEvent.setValue(null); + } + + public void deleteSelectedCheat() { + int position = mSelectedCheatPosition; + + setSelectedCheat(null, -1); + + CheatEngine.removeCheat(position); + + mCheatsNeedSaving = true; + load(); + + notifyCheatDeleted(position); + } + + public LiveData getOpenDetailsViewEvent() { + return mOpenDetailsViewEvent; + } + + public void openDetailsView() { + mOpenDetailsViewEvent.setValue(true); + mOpenDetailsViewEvent.setValue(false); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java new file mode 100644 index 000000000..762cdb80e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatDetailsFragment.java @@ -0,0 +1,174 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.EditText; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.appcompat.app.AlertDialog; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatDetailsFragment extends Fragment { + private View mRoot; + private ScrollView mScrollView; + private TextView mLabelName; + private EditText mEditName; + private EditText mEditNotes; + private EditText mEditCode; + private Button mButtonDelete; + private Button mButtonEdit; + private Button mButtonCancel; + private Button mButtonOk; + + private CheatsViewModel mViewModel; + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_details, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + mRoot = view.findViewById(R.id.root); + mScrollView = view.findViewById(R.id.scroll_view); + mLabelName = view.findViewById(R.id.label_name); + mEditName = view.findViewById(R.id.edit_name); + mEditNotes = view.findViewById(R.id.edit_notes); + mEditCode = view.findViewById(R.id.edit_code); + mButtonDelete = view.findViewById(R.id.button_delete); + mButtonEdit = view.findViewById(R.id.button_edit); + mButtonCancel = view.findViewById(R.id.button_cancel); + mButtonOk = view.findViewById(R.id.button_ok); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + mViewModel.getSelectedCheat().observe(getViewLifecycleOwner(), + this::onSelectedCheatUpdated); + mViewModel.getIsEditing().observe(getViewLifecycleOwner(), this::onIsEditingUpdated); + + mButtonDelete.setOnClickListener(this::onDeleteClicked); + mButtonEdit.setOnClickListener(this::onEditClicked); + mButtonCancel.setOnClickListener(this::onCancelClicked); + mButtonOk.setOnClickListener(this::onOkClicked); + + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> activity.onDetailsViewFocusChange(hasFocus)); + } + + private void clearEditErrors() { + mEditName.setError(null); + mEditCode.setError(null); + } + + private void onDeleteClicked(View view) { + String name = mEditName.getText().toString(); + + AlertDialog.Builder builder = new AlertDialog.Builder(requireContext()); + builder.setMessage(getString(R.string.cheats_delete_confirmation, name)); + builder.setPositiveButton(android.R.string.yes, + (dialog, i) -> mViewModel.deleteSelectedCheat()); + builder.setNegativeButton(android.R.string.no, null); + builder.show(); + } + + private void onEditClicked(View view) { + mViewModel.setIsEditing(true); + mButtonOk.requestFocus(); + } + + private void onCancelClicked(View view) { + mViewModel.setIsEditing(false); + onSelectedCheatUpdated(mViewModel.getSelectedCheat().getValue()); + mButtonDelete.requestFocus(); + } + + private void onOkClicked(View view) { + clearEditErrors(); + + String name = mEditName.getText().toString(); + String notes = mEditNotes.getText().toString(); + String code = mEditCode.getText().toString(); + + if (name.isEmpty()) { + mEditName.setError(getString(R.string.cheats_error_no_name)); + mScrollView.smoothScrollTo(0, mLabelName.getTop()); + return; + } else if (code.isEmpty()) { + mEditCode.setError(getString(R.string.cheats_error_no_code_lines)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + int validityResult = Cheat.isValidGatewayCode(code); + + if (validityResult != 0) { + mEditCode.setError(getString(R.string.cheats_error_on_line, validityResult)); + mScrollView.smoothScrollTo(0, mEditCode.getBottom()); + return; + } + + Cheat newCheat = Cheat.createGatewayCode(name, notes, code); + + if (mViewModel.getIsAdding().getValue()) { + mViewModel.finishAddingCheat(newCheat); + } else { + mViewModel.updateSelectedCheat(newCheat); + } + + mButtonEdit.requestFocus(); + } + + private void onSelectedCheatUpdated(@Nullable Cheat cheat) { + clearEditErrors(); + + boolean isEditing = mViewModel.getIsEditing().getValue(); + + mRoot.setVisibility(isEditing || cheat != null ? View.VISIBLE : View.GONE); + + // If the fragment was recreated while editing a cheat, it's vital that we + // don't repopulate the fields, otherwise the user's changes will be lost + if (!isEditing) { + if (cheat == null) { + mEditName.setText(""); + mEditNotes.setText(""); + mEditCode.setText(""); + } else { + mEditName.setText(cheat.getName()); + mEditNotes.setText(cheat.getNotes()); + mEditCode.setText(cheat.getCode()); + } + } + } + + private void onIsEditingUpdated(boolean isEditing) { + if (isEditing) { + mRoot.setVisibility(View.VISIBLE); + } + + mEditName.setEnabled(isEditing); + mEditNotes.setEnabled(isEditing); + mEditCode.setEnabled(isEditing); + + mButtonDelete.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonEdit.setVisibility(isEditing ? View.GONE : View.VISIBLE); + mButtonCancel.setVisibility(isEditing ? View.VISIBLE : View.GONE); + mButtonOk.setVisibility(isEditing ? View.VISIBLE : View.GONE); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java new file mode 100644 index 000000000..6c67a31d4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatListFragment.java @@ -0,0 +1,46 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import com.google.android.material.floatingactionbutton.FloatingActionButton; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.DividerItemDecoration; + +public class CheatListFragment extends Fragment { + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_cheat_list, container, false); + } + + @Override + public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) { + RecyclerView recyclerView = view.findViewById(R.id.cheat_list); + FloatingActionButton fab = view.findViewById(R.id.fab); + + CheatsActivity activity = (CheatsActivity) requireActivity(); + CheatsViewModel viewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + + recyclerView.setAdapter(new CheatsAdapter(activity, viewModel)); + recyclerView.setLayoutManager(new LinearLayoutManager(activity)); + recyclerView.addItemDecoration(new DividerItemDecoration(activity, null)); + + fab.setOnClickListener(v -> { + viewModel.startAddingCheat(); + viewModel.openDetailsView(); + }); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java new file mode 100644 index 000000000..8ba8f86e7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatViewHolder.java @@ -0,0 +1,56 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.CompoundButton; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.lifecycle.ViewModelProvider; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatViewHolder extends RecyclerView.ViewHolder + implements View.OnClickListener, CompoundButton.OnCheckedChangeListener { + private final View mRoot; + private final TextView mName; + private final CheckBox mCheckbox; + + private CheatsViewModel mViewModel; + private Cheat mCheat; + private int mPosition; + + public CheatViewHolder(@NonNull View itemView) { + super(itemView); + + mRoot = itemView.findViewById(R.id.root); + mName = itemView.findViewById(R.id.text_name); + mCheckbox = itemView.findViewById(R.id.checkbox); + } + + public void bind(CheatsActivity activity, Cheat cheat, int position) { + mCheckbox.setOnCheckedChangeListener(null); + + mViewModel = new ViewModelProvider(activity).get(CheatsViewModel.class); + mCheat = cheat; + mPosition = position; + + mName.setText(mCheat.getName()); + mCheckbox.setChecked(mCheat.getEnabled()); + + mRoot.setOnClickListener(this); + mCheckbox.setOnCheckedChangeListener(this); + } + + public void onClick(View root) { + mViewModel.setSelectedCheat(mCheat, mPosition); + mViewModel.openDetailsView(); + } + + public void onCheckedChanged(CompoundButton buttonView, boolean isChecked) { + mCheat.setEnabled(isChecked); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java new file mode 100644 index 000000000..a36bf427c --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsActivity.java @@ -0,0 +1,161 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.content.Context; +import android.content.Intent; +import android.os.Bundle; +import android.view.Menu; +import android.view.MenuInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.core.view.ViewCompat; +import androidx.lifecycle.ViewModelProvider; +import androidx.slidingpanelayout.widget.SlidingPaneLayout; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; +import org.citra.citra_emu.ui.TwoPaneOnBackPressedCallback; + +public class CheatsActivity extends AppCompatActivity + implements SlidingPaneLayout.PanelSlideListener { + private CheatsViewModel mViewModel; + + private SlidingPaneLayout mSlidingPaneLayout; + private View mCheatList; + private View mCheatDetails; + + private View mCheatListLastFocus; + private View mCheatDetailsLastFocus; + + public static void launch(Context context) { + Intent intent = new Intent(context, CheatsActivity.class); + context.startActivity(intent); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + mViewModel = new ViewModelProvider(this).get(CheatsViewModel.class); + mViewModel.load(); + + setContentView(R.layout.activity_cheats); + + mSlidingPaneLayout = findViewById(R.id.sliding_pane_layout); + mCheatList = findViewById(R.id.cheat_list); + mCheatDetails = findViewById(R.id.cheat_details); + + mCheatListLastFocus = mCheatList; + mCheatDetailsLastFocus = mCheatDetails; + + mSlidingPaneLayout.addPanelSlideListener(this); + + getOnBackPressedDispatcher().addCallback(this, + new TwoPaneOnBackPressedCallback(mSlidingPaneLayout)); + + mViewModel.getSelectedCheat().observe(this, this::onSelectedCheatChanged); + mViewModel.getIsEditing().observe(this, this::onIsEditingChanged); + onSelectedCheatChanged(mViewModel.getSelectedCheat().getValue()); + + mViewModel.getOpenDetailsViewEvent().observe(this, this::openDetailsView); + + // Show "Up" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onStop() { + super.onStop(); + + mViewModel.saveIfNeeded(); + } + + @Override + public void onPanelSlide(@NonNull View panel, float slideOffset) { + } + + @Override + public void onPanelOpened(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatDetailsLastFocus.requestFocus(rtl ? View.FOCUS_LEFT : View.FOCUS_RIGHT); + } + + @Override + public void onPanelClosed(@NonNull View panel) { + boolean rtl = ViewCompat.getLayoutDirection(panel) == ViewCompat.LAYOUT_DIRECTION_RTL; + mCheatListLastFocus.requestFocus(rtl ? View.FOCUS_RIGHT : View.FOCUS_LEFT); + } + + private void onIsEditingChanged(boolean isEditing) { + if (isEditing) { + mSlidingPaneLayout.setLockMode(SlidingPaneLayout.LOCK_MODE_UNLOCKED); + } + } + + private void onSelectedCheatChanged(Cheat selectedCheat) { + boolean cheatSelected = selectedCheat != null || mViewModel.getIsEditing().getValue(); + + if (!cheatSelected && mSlidingPaneLayout.isOpen()) { + mSlidingPaneLayout.close(); + } + + mSlidingPaneLayout.setLockMode(cheatSelected ? + SlidingPaneLayout.LOCK_MODE_UNLOCKED : SlidingPaneLayout.LOCK_MODE_LOCKED_CLOSED); + } + + public void onListViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatListLastFocus = mCheatList.findFocus(); + if (mCheatListLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.close(); + } + } + + public void onDetailsViewFocusChange(boolean hasFocus) { + if (hasFocus) { + mCheatDetailsLastFocus = mCheatDetails.findFocus(); + if (mCheatDetailsLastFocus == null) + throw new NullPointerException(); + + mSlidingPaneLayout.open(); + } + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + return true; + } + + private void openDetailsView(boolean open) { + if (open) { + mSlidingPaneLayout.open(); + } + } + + public static void setOnFocusChangeListenerRecursively(@NonNull View view, + View.OnFocusChangeListener listener) { + view.setOnFocusChangeListener(listener); + + if (view instanceof ViewGroup) { + ViewGroup viewGroup = (ViewGroup) view; + for (int i = 0; i < viewGroup.getChildCount(); i++) { + View child = viewGroup.getChildAt(i); + setOnFocusChangeListenerRecursively(child, listener); + } + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java new file mode 100644 index 000000000..9cb2ce8d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/cheats/ui/CheatsAdapter.java @@ -0,0 +1,72 @@ +package org.citra.citra_emu.features.cheats.ui; + +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.cheats.model.Cheat; +import org.citra.citra_emu.features.cheats.model.CheatsViewModel; + +public class CheatsAdapter extends RecyclerView.Adapter { + private final CheatsActivity mActivity; + private final CheatsViewModel mViewModel; + + public CheatsAdapter(CheatsActivity activity, CheatsViewModel viewModel) { + mActivity = activity; + mViewModel = viewModel; + + mViewModel.getCheatAddedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemInserted(position); + } + }); + + mViewModel.getCheatUpdatedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemChanged(position); + } + }); + + mViewModel.getCheatDeletedEvent().observe(activity, (position) -> { + if (position != null) { + notifyItemRemoved(position); + } + }); + } + + @NonNull + @Override + public CheatViewHolder onCreateViewHolder(@NonNull ViewGroup parent, int viewType) { + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + View cheatView = inflater.inflate(R.layout.list_item_cheat, parent, false); + addViewListeners(cheatView); + return new CheatViewHolder(cheatView); + } + + @Override + public void onBindViewHolder(@NonNull CheatViewHolder holder, int position) { + holder.bind(mActivity, getItemAt(position), position); + } + + @Override + public int getItemCount() { + return mViewModel.getCheats().length; + } + + private void addViewListeners(View view) { + // On a portrait phone screen (or other narrow screen), only one of the two panes are shown + // at the same time. If the user is navigating using a d-pad and moves focus to an element + // in the currently hidden pane, we need to manually show that pane. + CheatsActivity.setOnFocusChangeListenerRecursively(view, + (v, hasFocus) -> mActivity.onListViewFocusChange(hasFocus)); + } + + private Cheat getItemAt(int position) { + return mViewModel.getCheats()[position]; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java new file mode 100644 index 000000000..932dcf1d3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/BooleanSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class BooleanSetting extends Setting { + private boolean mValue; + + public BooleanSetting(String key, String section, boolean value) { + super(key, section); + mValue = value; + } + + public boolean getValue() { + return mValue; + } + + public void setValue(boolean value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue ? "True" : "False"; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java new file mode 100644 index 000000000..275f0ecea --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/FloatSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class FloatSetting extends Setting { + private float mValue; + + public FloatSetting(String key, String section, float value) { + super(key, section); + mValue = value; + } + + public float getValue() { + return mValue; + } + + public void setValue(float value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Float.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java new file mode 100644 index 000000000..f712e5bfa --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/IntSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class IntSetting extends Setting { + private int mValue; + + public IntSetting(String key, String section, int value) { + super(key, section); + mValue = value; + } + + public int getValue() { + return mValue; + } + + public void setValue(int value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return Integer.toString(mValue); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java new file mode 100644 index 000000000..b762847c9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Setting.java @@ -0,0 +1,42 @@ +package org.citra.citra_emu.features.settings.model; + +/** + * Abstraction for a setting item as read from / written to Citra's configuration ini files. + * These files generally consist of a key/value pair, though the type of value is ambiguous and + * must be inferred at read-time. The type of value determines which child of this class is used + * to represent the Setting. + */ +public abstract class Setting { + private String mKey; + private String mSection; + + /** + * Base constructor. + * + * @param key Everything to the left of the = in a line from the ini file. + * @param section The corresponding recent section header; e.g. [Core] or [Enhancements] without the brackets. + */ + public Setting(String key, String section) { + mKey = key; + mSection = section; + } + + /** + * @return The identifier used to write this setting to the ini file. + */ + public String getKey() { + return mKey; + } + + /** + * @return The name of the header under which this Setting should be written in the ini file. + */ + public String getSection() { + return mSection; + } + + /** + * @return A representation of this Setting's backing value converted to a String (e.g. for serialization). + */ + public abstract String getValueAsString(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java new file mode 100644 index 000000000..0a291aa6b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/SettingSection.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.model; + +import java.util.HashMap; + +/** + * A semantically-related group of Settings objects. These Settings are + * internally stored as a HashMap. + */ +public final class SettingSection { + private String mName; + + private HashMap mSettings = new HashMap<>(); + + /** + * Create a new SettingSection with no Settings in it. + * + * @param name The header of this section; e.g. [Core] or [Enhancements] without the brackets. + */ + public SettingSection(String name) { + mName = name; + } + + public String getName() { + return mName; + } + + /** + * Convenience method; inserts a value directly into the backing HashMap. + * + * @param setting The Setting to be inserted. + */ + public void putSetting(Setting setting) { + mSettings.put(setting.getKey(), setting); + } + + /** + * Convenience method; gets a value directly from the backing HashMap. + * + * @param key Used to retrieve the Setting. + * @return A Setting object (you should probably cast this before using) + */ + public Setting getSetting(String key) { + return mSettings.get(key); + } + + public HashMap getSettings() { + return mSettings; + } + + public void mergeSection(SettingSection settingSection) { + for (Setting setting : settingSection.mSettings.values()) { + putSetting(setting); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java new file mode 100644 index 000000000..9684966f2 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/Settings.java @@ -0,0 +1,132 @@ +package org.citra.citra_emu.features.settings.model; + +import android.text.TextUtils; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +import java.util.Arrays; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.TreeMap; + +public class Settings { + public static final String SECTION_PREMIUM = "Premium"; + public static final String SECTION_CORE = "Core"; + public static final String SECTION_SYSTEM = "System"; + public static final String SECTION_CAMERA = "Camera"; + public static final String SECTION_CONTROLS = "Controls"; + public static final String SECTION_RENDERER = "Renderer"; + public static final String SECTION_LAYOUT = "Layout"; + public static final String SECTION_UTILITY = "Utility"; + public static final String SECTION_AUDIO = "Audio"; + public static final String SECTION_DEBUG = "Debug"; + + private String gameId; + + private static final Map> configFileSectionsMap = new HashMap<>(); + + static { + configFileSectionsMap.put(SettingsFile.FILE_NAME_CONFIG, Arrays.asList(SECTION_PREMIUM, SECTION_CORE, SECTION_SYSTEM, SECTION_CAMERA, SECTION_CONTROLS, SECTION_RENDERER, SECTION_LAYOUT, SECTION_UTILITY, SECTION_AUDIO, SECTION_DEBUG)); + } + + /** + * A HashMap that constructs a new SettingSection instead of returning null + * when getting a key not already in the map + */ + public static final class SettingsSectionMap extends HashMap { + @Override + public SettingSection get(Object key) { + if (!(key instanceof String)) { + return null; + } + + String stringKey = (String) key; + + if (!super.containsKey(stringKey)) { + SettingSection section = new SettingSection(stringKey); + super.put(stringKey, section); + return section; + } + return super.get(key); + } + } + + private HashMap sections = new Settings.SettingsSectionMap(); + + public SettingSection getSection(String sectionName) { + return sections.get(sectionName); + } + + public boolean isEmpty() { + return sections.isEmpty(); + } + + public HashMap getSections() { + return sections; + } + + public void loadSettings(SettingsActivityView view) { + sections = new Settings.SettingsSectionMap(); + loadCitraSettings(view); + + if (!TextUtils.isEmpty(gameId)) { + loadCustomGameSettings(gameId, view); + } + } + + private void loadCitraSettings(SettingsActivityView view) { + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + sections.putAll(SettingsFile.readFile(fileName, view)); + } + } + + private void loadCustomGameSettings(String gameId, SettingsActivityView view) { + // custom game settings + mergeSections(SettingsFile.readCustomGameSettings(gameId, view)); + } + + private void mergeSections(HashMap updatedSections) { + for (Map.Entry entry : updatedSections.entrySet()) { + if (sections.containsKey(entry.getKey())) { + SettingSection originalSection = sections.get(entry.getKey()); + SettingSection updatedSection = entry.getValue(); + originalSection.mergeSection(updatedSection); + } else { + sections.put(entry.getKey(), entry.getValue()); + } + } + } + + public void loadSettings(String gameId, SettingsActivityView view) { + this.gameId = gameId; + loadSettings(view); + } + + public void saveSettings(SettingsActivityView view) { + if (TextUtils.isEmpty(gameId)) { + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.ini_saved), false); + + for (Map.Entry> entry : configFileSectionsMap.entrySet()) { + String fileName = entry.getKey(); + List sectionNames = entry.getValue(); + TreeMap iniSections = new TreeMap<>(); + for (String section : sectionNames) { + iniSections.put(section, sections.get(section)); + } + + SettingsFile.saveFile(fileName, iniSections, view); + } + } else { + // custom game settings + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.gameid_saved, gameId), false); + + SettingsFile.saveCustomGameSettings(gameId, sections); + } + + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java new file mode 100644 index 000000000..b906b7010 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/StringSetting.java @@ -0,0 +1,23 @@ +package org.citra.citra_emu.features.settings.model; + +public final class StringSetting extends Setting { + private String mValue; + + public StringSetting(String key, String section, String value) { + super(key, section); + mValue = value; + } + + public String getValue() { + return mValue; + } + + public void setValue(String value) { + mValue = value; + } + + @Override + public String getValueAsString() { + return mValue; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java new file mode 100644 index 000000000..baf40709f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/CheckBoxSetting.java @@ -0,0 +1,80 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.BooleanSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class CheckBoxSetting extends SettingsItem { + private boolean mDefaultValue; + private boolean mShowPerformanceWarning; + private SettingsFragmentView mView; + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mShowPerformanceWarning = false; + } + + public CheckBoxSetting(String key, String section, int titleId, int descriptionId, + boolean defaultValue, Setting setting, boolean show_performance_warning, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + mView = view; + mShowPerformanceWarning = show_performance_warning; + } + + public boolean isChecked() { + if (getSetting() == null) { + return mDefaultValue; + } + + // Try integer setting + try { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue() == 1; + } catch (ClassCastException exception) { + } + + // Try boolean setting + try { + BooleanSetting setting = (BooleanSetting) getSetting(); + return setting.getValue() == true; + } catch (ClassCastException exception) { + } + + return mDefaultValue; + } + + /** + * Write a value to the backing boolean. If that boolean was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param checked Pretty self explanatory. + * @return null if overwritten successfully; otherwise, a newly created BooleanSetting. + */ + public IntSetting setChecked(boolean checked) { + // Show a performance warning if the setting has been disabled + if (mShowPerformanceWarning && !checked) { + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.performance_warning), true); + } + + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), checked ? 1 : 0); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(checked ? 1 : 0); + return null; + } + } + + @Override + public int getType() { + return TYPE_CHECKBOX; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java new file mode 100644 index 000000000..afc3352cc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/DateTimeSetting.java @@ -0,0 +1,40 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public final class DateTimeSetting extends SettingsItem { + private String mDefaultValue; + + public DateTimeSetting(String key, String section, int titleId, int descriptionId, + String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mDefaultValue = defaultValue; + } + + public String getValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public StringSetting setSelectedValue(String datetime) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), datetime); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(datetime); + return null; + } + } + + @Override + public int getType() { + return TYPE_DATETIME_SETTING; + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java new file mode 100644 index 000000000..bac8876cd --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/HeaderSetting.java @@ -0,0 +1,14 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class HeaderSetting extends SettingsItem { + public HeaderSetting(String key, Setting setting, int titleId, int descriptionId) { + super(key, null, setting, titleId, descriptionId); + } + + @Override + public int getType() { + return SettingsItem.TYPE_HEADER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java new file mode 100644 index 000000000..e9141a208 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/InputBindingSetting.java @@ -0,0 +1,382 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.widget.Toast; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; + +public final class InputBindingSetting extends SettingsItem { + private static final String INPUT_MAPPING_PREFIX = "InputMapping"; + + public InputBindingSetting(String key, String section, int titleId, Setting setting) { + super(key, section, setting, titleId, 0); + } + + public String getValue() { + if (getSetting() == null) { + return ""; + } + + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } + + /** + * Returns true if this key is for the 3DS Circle Pad + */ + private boolean IsCirclePad() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for a horizontal axis for a 3DS analog stick or D-pad + */ + public boolean IsHorizontalOrientation() { + switch (getKey()) { + case SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS C-Stick + */ + private boolean IsCStick() { + switch (getKey()) { + case SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL: + case SettingsFile.KEY_CSTICK_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS D-Pad + */ + private boolean IsDPad() { + switch (getKey()) { + case SettingsFile.KEY_DPAD_AXIS_HORIZONTAL: + case SettingsFile.KEY_DPAD_AXIS_VERTICAL: + return true; + } + return false; + } + + /** + * Returns true if this key is for the 3DS L/R or ZL/ZR buttons. Note, these are not real + * triggers on the 3DS, but we support them as such on a physical gamepad. + */ + public boolean IsTrigger() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_L: + case SettingsFile.KEY_BUTTON_R: + case SettingsFile.KEY_BUTTON_ZL: + case SettingsFile.KEY_BUTTON_ZR: + return true; + } + return false; + } + + /** + * Returns true if a gamepad axis can be used to map this key. + */ + public boolean IsAxisMappingSupported() { + return IsCirclePad() || IsCStick() || IsDPad() || IsTrigger(); + } + + /** + * Returns true if a gamepad button can be used to map this key. + */ + private boolean IsButtonMappingSupported() { + return !IsAxisMappingSupported() || IsTrigger(); + } + + /** + * Returns the Citra button code for the settings key. + */ + private int getButtonCode() { + switch (getKey()) { + case SettingsFile.KEY_BUTTON_A: + return NativeLibrary.ButtonType.BUTTON_A; + case SettingsFile.KEY_BUTTON_B: + return NativeLibrary.ButtonType.BUTTON_B; + case SettingsFile.KEY_BUTTON_X: + return NativeLibrary.ButtonType.BUTTON_X; + case SettingsFile.KEY_BUTTON_Y: + return NativeLibrary.ButtonType.BUTTON_Y; + case SettingsFile.KEY_BUTTON_L: + return NativeLibrary.ButtonType.TRIGGER_L; + case SettingsFile.KEY_BUTTON_R: + return NativeLibrary.ButtonType.TRIGGER_R; + case SettingsFile.KEY_BUTTON_ZL: + return NativeLibrary.ButtonType.BUTTON_ZL; + case SettingsFile.KEY_BUTTON_ZR: + return NativeLibrary.ButtonType.BUTTON_ZR; + case SettingsFile.KEY_BUTTON_SELECT: + return NativeLibrary.ButtonType.BUTTON_SELECT; + case SettingsFile.KEY_BUTTON_START: + return NativeLibrary.ButtonType.BUTTON_START; + case SettingsFile.KEY_BUTTON_UP: + return NativeLibrary.ButtonType.DPAD_UP; + case SettingsFile.KEY_BUTTON_DOWN: + return NativeLibrary.ButtonType.DPAD_DOWN; + case SettingsFile.KEY_BUTTON_LEFT: + return NativeLibrary.ButtonType.DPAD_LEFT; + case SettingsFile.KEY_BUTTON_RIGHT: + return NativeLibrary.ButtonType.DPAD_RIGHT; + } + return -1; + } + + /** + * Returns the settings key for the specified Citra button code. + */ + private static String getButtonKey(int buttonCode) { + switch (buttonCode) { + case NativeLibrary.ButtonType.BUTTON_A: + return SettingsFile.KEY_BUTTON_A; + case NativeLibrary.ButtonType.BUTTON_B: + return SettingsFile.KEY_BUTTON_B; + case NativeLibrary.ButtonType.BUTTON_X: + return SettingsFile.KEY_BUTTON_X; + case NativeLibrary.ButtonType.BUTTON_Y: + return SettingsFile.KEY_BUTTON_Y; + case NativeLibrary.ButtonType.TRIGGER_L: + return SettingsFile.KEY_BUTTON_L; + case NativeLibrary.ButtonType.TRIGGER_R: + return SettingsFile.KEY_BUTTON_R; + case NativeLibrary.ButtonType.BUTTON_ZL: + return SettingsFile.KEY_BUTTON_ZL; + case NativeLibrary.ButtonType.BUTTON_ZR: + return SettingsFile.KEY_BUTTON_ZR; + case NativeLibrary.ButtonType.BUTTON_SELECT: + return SettingsFile.KEY_BUTTON_SELECT; + case NativeLibrary.ButtonType.BUTTON_START: + return SettingsFile.KEY_BUTTON_START; + case NativeLibrary.ButtonType.DPAD_UP: + return SettingsFile.KEY_BUTTON_UP; + case NativeLibrary.ButtonType.DPAD_DOWN: + return SettingsFile.KEY_BUTTON_DOWN; + case NativeLibrary.ButtonType.DPAD_LEFT: + return SettingsFile.KEY_BUTTON_LEFT; + case NativeLibrary.ButtonType.DPAD_RIGHT: + return SettingsFile.KEY_BUTTON_RIGHT; + } + return ""; + } + + /** + * Returns the key used to lookup the reverse mapping for this key, which is used to cleanup old + * settings on re-mapping or clearing of a setting. + */ + private String getReverseKey() { + String reverseKey = INPUT_MAPPING_PREFIX + "_ReverseMapping_" + getKey(); + + if (IsAxisMappingSupported() && !IsTrigger()) { + // Triggers are the only axis-supported mappings without orientation + reverseKey += "_" + (IsHorizontalOrientation() ? 0 : 1); + } + + return reverseKey; + } + + /** + * Removes the old mapping for this key from the settings, e.g. on user clearing the setting. + */ + public void removeOldMapping() { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Try remove all possible keys we wrote for this setting + String oldKey = preferences.getString(getReverseKey(), ""); + if (!oldKey.equals("")) { + editor.remove(getKey()); // Used for ui text + editor.remove(oldKey); // Used for button mapping + editor.remove(oldKey + "_GuestOrientation"); // Used for axis orientation + editor.remove(oldKey + "_GuestButton"); // Used for axis button + } + + // Apply changes + editor.apply(); + } + + /** + * Helper function to get the settings key for an gamepad button. + */ + public static String getInputButtonKey(int keyCode) { + return INPUT_MAPPING_PREFIX + "_Button_" + keyCode; + } + + /** + * Helper function to get the settings key for an gamepad axis. + */ + public static String getInputAxisKey(int axis) { + return INPUT_MAPPING_PREFIX + "_HostAxis_" + axis; + } + + /** + * Helper function to get the settings key for an gamepad axis button (stick or trigger). + */ + public static String getInputAxisButtonKey(int axis) { + return getInputAxisKey(axis) + "_GuestButton"; + } + + /** + * Helper function to get the settings key for an gamepad axis orientation. + */ + public static String getInputAxisOrientationKey(int axis) { + return getInputAxisKey(axis) + "_GuestOrientation"; + } + + /** + * Helper function to write a gamepad button mapping for the setting. + */ + private void WriteButtonMapping(String key) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Remove mapping for another setting using this input + int oldButtonCode = preferences.getInt(key, -1); + if (oldButtonCode != -1) { + String oldKey = getButtonKey(oldButtonCode); + editor.remove(oldKey); // Only need to remove UI text setting, others will be overwritten + } + + // Cleanup old mapping for this setting + removeOldMapping(); + + // Write new mapping + editor.putInt(key, getButtonCode()); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), key); + + // Apply changes + editor.apply(); + } + + /** + * Helper function to write a gamepad axis mapping for the setting. + */ + private void WriteAxisMapping(int axis, int value) { + // Get preferences editor + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + // Cleanup old mapping + removeOldMapping(); + + // Write new mapping + editor.putInt(getInputAxisOrientationKey(axis), IsHorizontalOrientation() ? 0 : 1); + editor.putInt(getInputAxisButtonKey(axis), value); + + // Write next reverse mapping for future cleanup + editor.putString(getReverseKey(), getInputAxisKey(axis)); + + // Apply changes + editor.apply(); + } + + /** + * Saves the provided key input setting as an Android preference. + * + * @param keyEvent KeyEvent of this key press. + */ + public void onKeyInput(KeyEvent keyEvent) { + if (!IsButtonMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_analog_only, Toast.LENGTH_LONG).show(); + return; + } + + InputDevice device = keyEvent.getDevice(); + + WriteButtonMapping(getInputButtonKey(keyEvent.getKeyCode())); + + String uiString = device.getName() + ": Button " + keyEvent.getKeyCode(); + setUiString(uiString); + } + + /** + * Saves the provided motion input setting as an Android preference. + * + * @param device InputDevice from which the input event originated. + * @param motionRange MotionRange of the movement + * @param axisDir Either '-' or '+' (currently unused) + */ + public void onMotionInput(InputDevice device, InputDevice.MotionRange motionRange, + char axisDir) { + if (!IsAxisMappingSupported()) { + Toast.makeText(CitraApplication.getAppContext(), R.string.input_message_button_only, Toast.LENGTH_LONG).show(); + return; + } + + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + int button; + if (IsCirclePad()) { + button = NativeLibrary.ButtonType.STICK_LEFT; + } else if (IsCStick()) { + button = NativeLibrary.ButtonType.STICK_C; + } else if (IsDPad()) { + button = NativeLibrary.ButtonType.DPAD; + } else { + button = getButtonCode(); + } + + WriteAxisMapping(motionRange.getAxis(), button); + + String uiString = device.getName() + ": Axis " + motionRange.getAxis(); + setUiString(uiString); + + editor.apply(); + } + + /** + * Sets the string to use in the configuration UI for the gamepad input. + */ + private StringSetting setUiString(String ui) { + SharedPreferences preferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + SharedPreferences.Editor editor = preferences.edit(); + + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), ""); + setSetting(setting); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + + editor.putString(setting.getKey(), ui); + editor.apply(); + + return null; + } + } + + @Override + public int getType() { + return TYPE_INPUT_BINDING; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java new file mode 100644 index 000000000..2b1793d3e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java @@ -0,0 +1,12 @@ +package org.citra.citra_emu.features.settings.model.view; + +public final class PremiumHeader extends SettingsItem { + public PremiumHeader() { + super(null, null, null, 0, 0); + } + + @Override + public int getType() { + return SettingsItem.TYPE_PREMIUM; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java new file mode 100644 index 000000000..c0560d2dc --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumSingleChoiceSetting.java @@ -0,0 +1,59 @@ +package org.citra.citra_emu.features.settings.model.view; + +import android.content.SharedPreferences; +import android.preference.PreferenceManager; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; + +public final class PremiumSingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + private SettingsFragmentView mView; + + private static SharedPreferences mPreferences = PreferenceManager.getDefaultSharedPreferences(CitraApplication.getAppContext()); + + public PremiumSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting, SettingsFragmentView view) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + mView = view; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + return mPreferences.getInt(getKey(), mDefaultValue); + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public void setSelectedValue(int selection) { + final SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt(getKey(), selection); + editor.apply(); + mView.showToastMessage(CitraApplication.getAppContext().getString(R.string.design_updated), false); + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java new file mode 100644 index 000000000..305352022 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java @@ -0,0 +1,107 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +/** + * ViewModel abstraction for an Item in the RecyclerView powering SettingsFragments. + * Each one corresponds to a {@link Setting} object, so this class's subclasses + * should vaguely correspond to those subclasses. There are a few with multiple analogues + * and a few with none (Headers, for example, do not correspond to anything in the ini + * file.) + */ +public abstract class SettingsItem { + public static final int TYPE_HEADER = 0; + public static final int TYPE_CHECKBOX = 1; + public static final int TYPE_SINGLE_CHOICE = 2; + public static final int TYPE_SLIDER = 3; + public static final int TYPE_SUBMENU = 4; + public static final int TYPE_INPUT_BINDING = 5; + public static final int TYPE_STRING_SINGLE_CHOICE = 6; + public static final int TYPE_DATETIME_SETTING = 7; + public static final int TYPE_PREMIUM = 8; + + private String mKey; + private String mSection; + + private Setting mSetting; + + private int mNameId; + private int mDescriptionId; + private boolean mIsPremium; + + /** + * Base constructor. Takes a key / section name in case the third parameter, the Setting, + * is null; in which case, one can be constructed and saved using the key / section. + * + * @param key Identifier for the Setting represented by this Item. + * @param section Section to which the Setting belongs. + * @param setting A possibly-null backing Setting, to be modified on UI events. + * @param nameId Resource ID for a text string to be displayed as this setting's name. + * @param descriptionId Resource ID for a text string to be displayed as this setting's description. + */ + public SettingsItem(String key, String section, Setting setting, int nameId, + int descriptionId) { + mKey = key; + mSection = section; + mSetting = setting; + mNameId = nameId; + mDescriptionId = descriptionId; + mIsPremium = (section == Settings.SECTION_PREMIUM); + } + + /** + * @return The identifier for the backing Setting. + */ + public String getKey() { + return mKey; + } + + /** + * @return The header under which the backing Setting belongs. + */ + public String getSection() { + return mSection; + } + + /** + * @return The backing Setting, possibly null. + */ + public Setting getSetting() { + return mSetting; + } + + /** + * Replace the backing setting with a new one. Generally used in cases where + * the backing setting is null. + * + * @param setting A non-null Setting. + */ + public void setSetting(Setting setting) { + mSetting = setting; + } + + /** + * @return A resource ID for a text string representing this Setting's name. + */ + public int getNameId() { + return mNameId; + } + + public int getDescriptionId() { + return mDescriptionId; + } + + public boolean isPremium() { + return mIsPremium; + } + + /** + * Used by {@link SettingsAdapter}'s onCreateViewHolder() + * method to determine which type of ViewHolder should be created. + * + * @return An integer (ideally, one of the constants defined in this file) + */ + public abstract int getType(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java new file mode 100644 index 000000000..ee9d225d6 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SingleChoiceSetting.java @@ -0,0 +1,60 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SingleChoiceSetting extends SettingsItem { + private int mDefaultValue; + + private int mChoicesId; + private int mValuesId; + + public SingleChoiceSetting(String key, String section, int titleId, int descriptionId, + int choicesId, int valuesId, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public int getChoicesId() { + return mChoicesId; + } + + public int getValuesId() { + return mValuesId; + } + + public int getSelectedValue() { + if (getSetting() != null) { + IntSetting setting = (IntSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java new file mode 100644 index 000000000..551b13f99 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SliderSetting.java @@ -0,0 +1,101 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.utils.Log; + +public final class SliderSetting extends SettingsItem { + private int mMin; + private int mMax; + private int mDefaultValue; + + private String mUnits; + + public SliderSetting(String key, String section, int titleId, int descriptionId, + int min, int max, String units, int defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mMin = min; + mMax = max; + mUnits = units; + mDefaultValue = defaultValue; + } + + public int getMin() { + return mMin; + } + + public int getMax() { + return mMax; + } + + public int getDefaultValue() { + return mDefaultValue; + } + + public int getSelectedValue() { + Setting setting = getSetting(); + + if (setting == null) { + return mDefaultValue; + } + + if (setting instanceof IntSetting) { + IntSetting intSetting = (IntSetting) setting; + return intSetting.getValue(); + } else if (setting instanceof FloatSetting) { + FloatSetting floatSetting = (FloatSetting) setting; + return Math.round(floatSetting.getValue()); + } else { + Log.error("[SliderSetting] Error casting setting type."); + return -1; + } + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public IntSetting setSelectedValue(int selection) { + if (getSetting() == null) { + IntSetting setting = new IntSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + IntSetting setting = (IntSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + /** + * Write a value to the backing float. If that float was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the float. + * @return null if overwritten successfully otherwise; a newly created FloatSetting. + */ + public FloatSetting setSelectedValue(float selection) { + if (getSetting() == null) { + FloatSetting setting = new FloatSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + FloatSetting setting = (FloatSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + public String getUnits() { + return mUnits; + } + + @Override + public int getType() { + return TYPE_SLIDER; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java new file mode 100644 index 000000000..057145d9d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/StringSingleChoiceSetting.java @@ -0,0 +1,82 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.StringSetting; + +public class StringSingleChoiceSetting extends SettingsItem { + private String mDefaultValue; + + private String[] mChoicesId; + private String[] mValuesId; + + public StringSingleChoiceSetting(String key, String section, int titleId, int descriptionId, + String[] choicesId, String[] valuesId, String defaultValue, Setting setting) { + super(key, section, setting, titleId, descriptionId); + mValuesId = valuesId; + mChoicesId = choicesId; + mDefaultValue = defaultValue; + } + + public String[] getChoicesId() { + return mChoicesId; + } + + public String[] getValuesId() { + return mValuesId; + } + + public String getValueAt(int index) { + if (mValuesId == null) + return null; + + if (index >= 0 && index < mValuesId.length) { + return mValuesId[index]; + } + + return ""; + } + + public String getSelectedValue() { + if (getSetting() != null) { + StringSetting setting = (StringSetting) getSetting(); + return setting.getValue(); + } else { + return mDefaultValue; + } + } + + public int getSelectValueIndex() { + String selectedValue = getSelectedValue(); + for (int i = 0; i < mValuesId.length; i++) { + if (mValuesId[i].equals(selectedValue)) { + return i; + } + } + + return -1; + } + + /** + * Write a value to the backing int. If that int was previously null, + * initializes a new one and returns it, so it can be added to the Hashmap. + * + * @param selection New value of the int. + * @return null if overwritten successfully otherwise; a newly created IntSetting. + */ + public StringSetting setSelectedValue(String selection) { + if (getSetting() == null) { + StringSetting setting = new StringSetting(getKey(), getSection(), selection); + setSetting(setting); + return setting; + } else { + StringSetting setting = (StringSetting) getSetting(); + setting.setValue(selection); + return null; + } + } + + @Override + public int getType() { + return TYPE_STRING_SINGLE_CHOICE; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java new file mode 100644 index 000000000..9d44a923f --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SubmenuSetting.java @@ -0,0 +1,21 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class SubmenuSetting extends SettingsItem { + private String mMenuKey; + + public SubmenuSetting(String key, Setting setting, int titleId, int descriptionId, String menuKey) { + super(key, null, setting, titleId, descriptionId); + mMenuKey = menuKey; + } + + public String getMenuKey() { + return mMenuKey; + } + + @Override + public int getType() { + return TYPE_SUBMENU; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java new file mode 100644 index 000000000..23c3c4c9e --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivity.java @@ -0,0 +1,215 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.ProgressDialog; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.os.Bundle; +import android.provider.Settings; +import android.view.Menu; +import android.view.MenuInflater; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.appcompat.app.AppCompatActivity; +import androidx.fragment.app.FragmentTransaction; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +public final class SettingsActivity extends AppCompatActivity implements SettingsActivityView { + private static final String ARG_MENU_TAG = "menu_tag"; + private static final String ARG_GAME_ID = "game_id"; + private static final String FRAGMENT_TAG = "settings"; + private SettingsActivityPresenter mPresenter = new SettingsActivityPresenter(this); + + private ProgressDialog dialog; + + public static void launch(Context context, String menuTag, String gameId) { + Intent settings = new Intent(context, SettingsActivity.class); + settings.putExtra(ARG_MENU_TAG, menuTag); + settings.putExtra(ARG_GAME_ID, gameId); + context.startActivity(settings); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setContentView(R.layout.activity_settings); + + Intent launcher = getIntent(); + String gameID = launcher.getStringExtra(ARG_GAME_ID); + String menuTag = launcher.getStringExtra(ARG_MENU_TAG); + + mPresenter.onCreate(savedInstanceState, menuTag, gameID); + + // Show "Back" button in the action bar for navigation + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + } + + @Override + public boolean onSupportNavigateUp() { + onBackPressed(); + + return true; + } + + @Override + public boolean onCreateOptionsMenu(Menu menu) { + MenuInflater inflater = getMenuInflater(); + inflater.inflate(R.menu.menu_settings, menu); + + return true; + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + // Critical: If super method is not called, rotations will be busted. + super.onSaveInstanceState(outState); + mPresenter.saveState(outState); + } + + @Override + protected void onStart() { + super.onStart(); + mPresenter.onStart(); + } + + /** + * If this is called, the user has left the settings screen (potentially through the + * home button) and will expect their changes to be persisted. So we kick off an + * IntentService which will do so on a background thread. + */ + @Override + protected void onStop() { + super.onStop(); + + mPresenter.onStop(isFinishing()); + + // Update framebuffer layout when closing the settings + NativeLibrary.NotifyOrientationChange(EmulationMenuSettings.getLandscapeScreenLayout(), + getWindowManager().getDefaultDisplay().getRotation()); + } + + @Override + public void showSettingsFragment(String menuTag, boolean addToStack, String gameID) { + if (!addToStack && getFragment() != null) { + return; + } + + FragmentTransaction transaction = getSupportFragmentManager().beginTransaction(); + + if (addToStack) { + if (areSystemAnimationsEnabled()) { + transaction.setCustomAnimations( + R.animator.settings_enter, + R.animator.settings_exit, + R.animator.settings_pop_enter, + R.animator.setttings_pop_exit); + } + + transaction.addToBackStack(null); + } + transaction.replace(R.id.frame_content, SettingsFragment.newInstance(menuTag, gameID), FRAGMENT_TAG); + + transaction.commit(); + } + + private boolean areSystemAnimationsEnabled() { + float duration = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.ANIMATOR_DURATION_SCALE, 1); + float transition = Settings.Global.getFloat( + getContentResolver(), + Settings.Global.TRANSITION_ANIMATION_SCALE, 1); + return duration != 0 && transition != 0; + } + + @Override + public void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter) { + LocalBroadcastManager.getInstance(this).registerReceiver( + receiver, + filter); + DirectoryInitialization.start(this); + } + + @Override + public void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver) { + LocalBroadcastManager.getInstance(this).unregisterReceiver(receiver); + } + + @Override + public void showLoading() { + if (dialog == null) { + dialog = new ProgressDialog(this); + dialog.setMessage(getString(R.string.load_settings)); + dialog.setIndeterminate(true); + } + + dialog.show(); + } + + @Override + public void hideLoading() { + dialog.dismiss(); + } + + @Override + public void showPermissionNeededHint() { + Toast.makeText(this, R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public void showExternalStorageNotMountedHint() { + Toast.makeText(this, R.string.external_storage_not_mounted, Toast.LENGTH_SHORT) + .show(); + } + + @Override + public org.citra.citra_emu.features.settings.model.Settings getSettings() { + return mPresenter.getSettings(); + } + + @Override + public void setSettings(org.citra.citra_emu.features.settings.model.Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void onSettingsFileLoaded(org.citra.citra_emu.features.settings.model.Settings settings) { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.onSettingsFileLoaded(settings); + } + } + + @Override + public void onSettingsFileNotFound() { + SettingsFragmentView fragment = getFragment(); + + if (fragment != null) { + fragment.loadDefaultSettings(); + } + } + + @Override + public void showToastMessage(String message, boolean is_long) { + Toast.makeText(this, message, is_long ? Toast.LENGTH_LONG : Toast.LENGTH_SHORT).show(); + } + + @Override + public void onSettingChanged() { + mPresenter.onSettingChanged(); + } + + private SettingsFragment getFragment() { + return (SettingsFragment) getSupportFragmentManager().findFragmentByTag(FRAGMENT_TAG); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java new file mode 100644 index 000000000..0d63873bb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityPresenter.java @@ -0,0 +1,124 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; +import android.os.Bundle; +import android.text.TextUtils; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.Log; +import org.citra.citra_emu.utils.ThemeUtil; + +import java.io.File; + +public final class SettingsActivityPresenter { + private static final String KEY_SHOULD_SAVE = "should_save"; + + private SettingsActivityView mView; + + private Settings mSettings = new Settings(); + + private boolean mShouldSave; + + private DirectoryStateReceiver directoryStateReceiver; + + private String menuTag; + private String gameId; + + public SettingsActivityPresenter(SettingsActivityView view) { + mView = view; + } + + public void onCreate(Bundle savedInstanceState, String menuTag, String gameId) { + if (savedInstanceState == null) { + this.menuTag = menuTag; + this.gameId = gameId; + } else { + mShouldSave = savedInstanceState.getBoolean(KEY_SHOULD_SAVE); + } + } + + public void onStart() { + prepareCitraDirectoriesIfNeeded(); + } + + void loadSettingsUI() { + if (mSettings.isEmpty()) { + if (!TextUtils.isEmpty(gameId)) { + mSettings.loadSettings(gameId, mView); + } else { + mSettings.loadSettings(mView); + } + } + + mView.showSettingsFragment(menuTag, false, gameId); + mView.onSettingsFileLoaded(mSettings); + } + + private void prepareCitraDirectoriesIfNeeded() { + File configFile = new File(DirectoryInitialization.getUserDirectory() + "/config/" + SettingsFile.FILE_NAME_CONFIG + ".ini"); + if (!configFile.exists()) { + Log.error("Citra config file could not be found!"); + } + if (DirectoryInitialization.areCitraDirectoriesReady()) { + loadSettingsUI(); + } else { + mView.showLoading(); + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mView.hideLoading(); + loadSettingsUI(); + } else if (directoryInitializationState == DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + mView.showPermissionNeededHint(); + mView.hideLoading(); + } else if (directoryInitializationState == DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + mView.showExternalStorageNotMountedHint(); + mView.hideLoading(); + } + }); + + mView.startDirectoryInitializationService(directoryStateReceiver, statusIntentFilter); + } + } + + public void setSettings(Settings settings) { + mSettings = settings; + } + + public Settings getSettings() { + return mSettings; + } + + public void onStop(boolean finishing) { + if (directoryStateReceiver != null) { + mView.stopListeningToDirectoryInitializationService(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mSettings != null && finishing && mShouldSave) { + Log.debug("[SettingsActivity] Settings activity stopping. Saving settings to INI..."); + mSettings.saveSettings(mView); + } + + ThemeUtil.applyTheme(); + + NativeLibrary.ReloadSettings(); + } + + public void onSettingChanged() { + mShouldSave = true; + } + + public void saveState(Bundle outState) { + outState.putBoolean(KEY_SHOULD_SAVE, mShouldSave); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java new file mode 100644 index 000000000..0d26d48a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsActivityView.java @@ -0,0 +1,103 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.IntentFilter; + +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.utils.DirectoryStateReceiver; + +/** + * Abstraction for the Activity that manages SettingsFragments. + */ +public interface SettingsActivityView { + /** + * Show a new SettingsFragment. + * + * @param menuTag Identifier for the settings group that should be displayed. + * @param addToStack Whether or not this fragment should replace a previous one. + */ + void showSettingsFragment(String menuTag, boolean addToStack, String gameId); + + /** + * Called by a contained Fragment to get access to the Setting HashMap + * loaded from disk, so that each Fragment doesn't need to perform its own + * read operation. + * + * @return A possibly null HashMap of Settings. + */ + Settings getSettings(); + + /** + * Used to provide the Activity with Settings HashMaps if a Fragment already + * has one; for example, if a rotation occurs, the Fragment will not be killed, + * but the Activity will, so the Activity needs to have its HashMaps resupplied. + * + * @param settings The ArrayList of all the Settings HashMaps. + */ + void setSettings(Settings settings); + + /** + * Called when an asynchronous load operation completes. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Called when an asynchronous load operation fails. + */ + void onSettingsFileNotFound(); + + /** + * Display a popup text message on screen. + * + * @param message The contents of the onscreen message. + * @param is_long Whether this should be a long Toast or short one. + */ + void showToastMessage(String message, boolean is_long); + + /** + * End the activity. + */ + void finish(); + + /** + * Called by a containing Fragment to tell the Activity that a setting was changed; + * unless this has been called, the Activity will not save to disk. + */ + void onSettingChanged(); + + /** + * Show loading dialog while loading the settings + */ + void showLoading(); + + /** + * Hide the loading the dialog + */ + void hideLoading(); + + /** + * Show a hint to the user that the app needs write to external storage access + */ + void showPermissionNeededHint(); + + /** + * Show a hint to the user that the app needs the external storage to be mounted + */ + void showExternalStorageNotMountedHint(); + + /** + * Start the DirectoryInitialization and listen for the result. + * + * @param receiver the broadcast receiver for the DirectoryInitialization + * @param filter the Intent broadcasts to be received. + */ + void startDirectoryInitializationService(DirectoryStateReceiver receiver, IntentFilter filter); + + /** + * Stop listening to the DirectoryInitialization. + * + * @param receiver The broadcast receiver to unregister. + */ + void stopListeningToDirectoryInitializationService(DirectoryStateReceiver receiver); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java new file mode 100644 index 000000000..bfd7c71a9 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java @@ -0,0 +1,487 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.content.DialogInterface; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.DatePicker; +import android.widget.SeekBar; +import android.widget.TextView; +import android.widget.TimePicker; + +import androidx.appcompat.app.AlertDialog; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.dialogs.MotionAlertDialog; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; +import org.citra.citra_emu.ui.main.MainActivity; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; + +public final class SettingsAdapter extends RecyclerView.Adapter + implements DialogInterface.OnClickListener, SeekBar.OnSeekBarChangeListener { + private SettingsFragmentView mView; + private Context mContext; + private ArrayList mSettings; + + private SettingsItem mClickedItem; + private int mClickedPosition; + private int mSeekbarProgress; + + private AlertDialog mDialog; + private TextView mTextSliderValue; + + public SettingsAdapter(SettingsFragmentView view, Context context) { + mView = view; + mContext = context; + mClickedPosition = -1; + } + + @Override + public SettingViewHolder onCreateViewHolder(ViewGroup parent, int viewType) { + View view; + LayoutInflater inflater = LayoutInflater.from(parent.getContext()); + + switch (viewType) { + case SettingsItem.TYPE_HEADER: + view = inflater.inflate(R.layout.list_item_settings_header, parent, false); + return new HeaderViewHolder(view, this); + + case SettingsItem.TYPE_CHECKBOX: + view = inflater.inflate(R.layout.list_item_setting_checkbox, parent, false); + return new CheckBoxSettingViewHolder(view, this); + + case SettingsItem.TYPE_SINGLE_CHOICE: + case SettingsItem.TYPE_STRING_SINGLE_CHOICE: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SingleChoiceViewHolder(view, this); + + case SettingsItem.TYPE_SLIDER: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SliderViewHolder(view, this); + + case SettingsItem.TYPE_SUBMENU: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new SubmenuViewHolder(view, this); + + case SettingsItem.TYPE_INPUT_BINDING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new InputBindingSettingViewHolder(view, this, mContext); + + case SettingsItem.TYPE_DATETIME_SETTING: + view = inflater.inflate(R.layout.list_item_setting, parent, false); + return new DateTimeViewHolder(view, this); + + case SettingsItem.TYPE_PREMIUM: + view = inflater.inflate(R.layout.premium_item_setting, parent, false); + return new PremiumViewHolder(view, this, mView); + + default: + Log.error("[SettingsAdapter] Invalid view type: " + viewType); + return null; + } + } + + @Override + public void onBindViewHolder(SettingViewHolder holder, int position) { + holder.bind(getItem(position)); + } + + private SettingsItem getItem(int position) { + return mSettings.get(position); + } + + @Override + public int getItemCount() { + if (mSettings != null) { + return mSettings.size(); + } else { + return 0; + } + } + + @Override + public int getItemViewType(int position) { + return getItem(position).getType(); + } + + public void setSettings(ArrayList settings) { + mSettings = settings; + notifyDataSetChanged(); + } + + public void onBooleanClick(CheckBoxSetting item, int position, boolean checked) { + IntSetting setting = item.setChecked(checked); + notifyItemChanged(position); + + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item) { + mClickedItem = item; + + int value = getSelectionForSingleChoiceValue(item); + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), value, this); + + mDialog = builder.show(); + } + + public void onSingleChoiceClick(SingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onSingleChoiceClick(PremiumSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item)); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item) { + mClickedItem = item; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + builder.setTitle(item.getNameId()); + builder.setSingleChoiceItems(item.getChoicesId(), item.getSelectValueIndex(), this); + + mDialog = builder.show(); + } + + public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) { + mClickedPosition = position; + + if (!item.isPremium() || MainActivity.isPremiumActive()) { + // Setting is either not Premium, or the user has Premium + onStringSingleChoiceClick(item); + return; + } + + // User needs Premium, invoke the billing flow + MainActivity.invokePremiumBilling(() -> onStringSingleChoiceClick(item)); + } + + DialogInterface.OnClickListener defaultCancelListener = (dialog, which) -> closeDialog(); + + public void onDateTimeClick(DateTimeSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.sysclock_datetime_picker, null); + + DatePicker dp = view.findViewById(R.id.date_picker); + TimePicker tp = view.findViewById(R.id.time_picker); + + //set date and time to substrings of settingValue; format = 2018-12-24 04:20:69 (alright maybe not that 69) + String settingValue = item.getValue(); + dp.updateDate(Integer.parseInt(settingValue.substring(0, 4)), Integer.parseInt(settingValue.substring(5, 7)) - 1, Integer.parseInt(settingValue.substring(8, 10))); + + tp.setIs24HourView(true); + tp.setHour(Integer.parseInt(settingValue.substring(11, 13))); + tp.setMinute(Integer.parseInt(settingValue.substring(14, 16))); + + DialogInterface.OnClickListener ok = (dialog, which) -> { + //set it + int year = dp.getYear(); + if (year < 2000) { + year = 2000; + } + String month = ("00" + (dp.getMonth() + 1)).substring(String.valueOf(dp.getMonth() + 1).length()); + String day = ("00" + dp.getDayOfMonth()).substring(String.valueOf(dp.getDayOfMonth()).length()); + String hr = ("00" + tp.getHour()).substring(String.valueOf(tp.getHour()).length()); + String min = ("00" + tp.getMinute()).substring(String.valueOf(tp.getMinute()).length()); + String datetime = year + "-" + month + "-" + day + " " + hr + ":" + min + ":01"; + + StringSetting setting = item.setSelectedValue(datetime); + if (setting != null) { + mView.putSetting(setting); + } + + mView.onSettingChanged(); + + mClickedItem = null; + closeDialog(); + }; + + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, ok); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + mDialog = builder.show(); + } + + public void onSliderClick(SliderSetting item, int position) { + mClickedItem = item; + mClickedPosition = position; + mSeekbarProgress = item.getSelectedValue(); + AlertDialog.Builder builder = new AlertDialog.Builder(mView.getActivity()); + + LayoutInflater inflater = LayoutInflater.from(mView.getActivity()); + View view = inflater.inflate(R.layout.dialog_seekbar, null); + + SeekBar seekbar = view.findViewById(R.id.seekbar); + + builder.setTitle(item.getNameId()); + builder.setView(view); + builder.setPositiveButton(android.R.string.ok, this); + builder.setNegativeButton(android.R.string.cancel, defaultCancelListener); + builder.setNeutralButton(R.string.slider_default, (DialogInterface dialog, int which) -> { + seekbar.setProgress(item.getDefaultValue()); + onClick(dialog, which); + }); + mDialog = builder.show(); + + mTextSliderValue = view.findViewById(R.id.text_value); + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + + TextView units = view.findViewById(R.id.text_units); + units.setText(item.getUnits()); + + seekbar.setMin(item.getMin()); + seekbar.setMax(item.getMax()); + seekbar.setProgress(mSeekbarProgress); + + seekbar.setOnSeekBarChangeListener(this); + } + + public void onSubmenuClick(SubmenuSetting item) { + mView.loadSubMenu(item.getMenuKey()); + } + + public void onInputBindingClick(final InputBindingSetting item, final int position) { + final MotionAlertDialog dialog = new MotionAlertDialog(mContext, item); + dialog.setTitle(R.string.input_binding); + + int messageResId = R.string.input_binding_description; + if (item.IsAxisMappingSupported() && !item.IsTrigger()) { + // Use specialized message for axis left/right or up/down + if (item.IsHorizontalOrientation()) { + messageResId = R.string.input_binding_description_horizontal_axis; + } else { + messageResId = R.string.input_binding_description_vertical_axis; + } + } + + dialog.setMessage(String.format(mContext.getString(messageResId), mContext.getString(item.getNameId()))); + dialog.setButton(AlertDialog.BUTTON_NEGATIVE, mContext.getString(android.R.string.cancel), this); + dialog.setButton(AlertDialog.BUTTON_NEUTRAL, mContext.getString(R.string.clear), (dialogInterface, i) -> + item.removeOldMapping()); + dialog.setOnDismissListener(dialog1 -> + { + StringSetting setting = new StringSetting(item.getKey(), item.getSection(), item.getValue()); + notifyItemChanged(position); + + mView.putSetting(setting); + + mView.onSettingChanged(); + }); + dialog.setCanceledOnTouchOutside(false); + dialog.show(); + } + + @Override + public void onClick(DialogInterface dialog, int which) { + if (mClickedItem instanceof SingleChoiceSetting) { + SingleChoiceSetting scSetting = (SingleChoiceSetting) mClickedItem; + + int value = getValueForSingleChoiceSelection(scSetting, which); + if (scSetting.getSelectedValue() != value) { + mView.onSettingChanged(); + } + + // Get the backing Setting, which may be null (if for example it was missing from the file) + IntSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting scSetting = (PremiumSingleChoiceSetting) mClickedItem; + scSetting.setSelectedValue(getValueForSingleChoiceSelection(scSetting, which)); + closeDialog(); + } else if (mClickedItem instanceof StringSingleChoiceSetting) { + StringSingleChoiceSetting scSetting = (StringSingleChoiceSetting) mClickedItem; + String value = scSetting.getValueAt(which); + if (!scSetting.getSelectedValue().equals(value)) + mView.onSettingChanged(); + + StringSetting setting = scSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + + closeDialog(); + } else if (mClickedItem instanceof SliderSetting) { + SliderSetting sliderSetting = (SliderSetting) mClickedItem; + if (sliderSetting.getSelectedValue() != mSeekbarProgress) { + mView.onSettingChanged(); + } + + if (sliderSetting.getSetting() instanceof FloatSetting) { + float value = (float) mSeekbarProgress; + + FloatSetting setting = sliderSetting.setSelectedValue(value); + if (setting != null) { + mView.putSetting(setting); + } + } else { + IntSetting setting = sliderSetting.setSelectedValue(mSeekbarProgress); + if (setting != null) { + mView.putSetting(setting); + } + } + + closeDialog(); + } + + mClickedItem = null; + mSeekbarProgress = -1; + } + + public void closeDialog() { + if (mDialog != null) { + if (mClickedPosition != -1) { + notifyItemChanged(mClickedPosition); + mClickedPosition = -1; + } + mDialog.dismiss(); + mDialog = null; + } + } + + @Override + public void onProgressChanged(SeekBar seekBar, int progress, boolean fromUser) { + mSeekbarProgress = progress; + mTextSliderValue.setText(String.valueOf(mSeekbarProgress)); + } + + @Override + public void onStartTrackingTouch(SeekBar seekBar) { + } + + @Override + public void onStopTrackingTouch(SeekBar seekBar) { + } + + private int getValueForSingleChoiceSelection(SingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getValueForSingleChoiceSelection(PremiumSingleChoiceSetting item, int which) { + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + return valuesArray[which]; + } else { + return which; + } + } + + private int getSelectionForSingleChoiceValue(SingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } + + private int getSelectionForSingleChoiceValue(PremiumSingleChoiceSetting item) { + int value = item.getSelectedValue(); + int valuesId = item.getValuesId(); + + if (valuesId > 0) { + int[] valuesArray = mContext.getResources().getIntArray(valuesId); + for (int index = 0; index < valuesArray.length; index++) { + int current = valuesArray[index]; + if (current == value) { + return index; + } + } + } else { + return value; + } + + return -1; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java new file mode 100644 index 000000000..5799dcb8d --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragment.java @@ -0,0 +1,136 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.os.Bundle; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.fragment.app.Fragment; +import androidx.recyclerview.widget.LinearLayoutManager; +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.ui.DividerItemDecoration; + +import java.util.ArrayList; + +public final class SettingsFragment extends Fragment implements SettingsFragmentView { + private static final String ARGUMENT_MENU_TAG = "menu_tag"; + private static final String ARGUMENT_GAME_ID = "game_id"; + + private SettingsFragmentPresenter mPresenter = new SettingsFragmentPresenter(this); + private SettingsActivityView mActivity; + + private SettingsAdapter mAdapter; + + public static Fragment newInstance(String menuTag, String gameId) { + SettingsFragment fragment = new SettingsFragment(); + + Bundle arguments = new Bundle(); + arguments.putString(ARGUMENT_MENU_TAG, menuTag); + arguments.putString(ARGUMENT_GAME_ID, gameId); + + fragment.setArguments(arguments); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + mActivity = (SettingsActivityView) context; + mPresenter.onAttach(); + } + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + setRetainInstance(true); + String menuTag = getArguments().getString(ARGUMENT_MENU_TAG); + String gameId = getArguments().getString(ARGUMENT_GAME_ID); + + mAdapter = new SettingsAdapter(this, getActivity()); + + mPresenter.onCreate(menuTag, gameId); + } + + @Nullable + @Override + public View onCreateView(LayoutInflater inflater, @Nullable ViewGroup container, @Nullable Bundle savedInstanceState) { + return inflater.inflate(R.layout.fragment_settings, container, false); + } + + @Override + public void onViewCreated(View view, @Nullable Bundle savedInstanceState) { + LinearLayoutManager manager = new LinearLayoutManager(getActivity()); + + RecyclerView recyclerView = view.findViewById(R.id.list_settings); + + recyclerView.setAdapter(mAdapter); + recyclerView.setLayoutManager(manager); + recyclerView.addItemDecoration(new DividerItemDecoration(getActivity(), null)); + + SettingsActivityView activity = (SettingsActivityView) getActivity(); + + mPresenter.onViewCreated(activity.getSettings()); + } + + @Override + public void onDetach() { + super.onDetach(); + mActivity = null; + + if (mAdapter != null) { + mAdapter.closeDialog(); + } + } + + @Override + public void onSettingsFileLoaded(Settings settings) { + mPresenter.setSettings(settings); + } + + @Override + public void passSettingsToActivity(Settings settings) { + if (mActivity != null) { + mActivity.setSettings(settings); + } + } + + @Override + public void showSettingsList(ArrayList settingsList) { + mAdapter.setSettings(settingsList); + } + + @Override + public void loadDefaultSettings() { + mPresenter.loadDefaultSettings(); + } + + @Override + public void loadSubMenu(String menuKey) { + mActivity.showSettingsFragment(menuKey, true, getArguments().getString(ARGUMENT_GAME_ID)); + } + + @Override + public void showToastMessage(String message, boolean is_long) { + mActivity.showToastMessage(message, is_long); + } + + @Override + public void putSetting(Setting setting) { + mPresenter.putSetting(setting); + } + + @Override + public void onSettingChanged() { + mActivity.onSettingChanged(); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java new file mode 100644 index 000000000..31f3e68eb --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java @@ -0,0 +1,416 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.app.Activity; +import android.content.Context; +import android.hardware.camera2.CameraAccessException; +import android.hardware.camera2.CameraCharacteristics; +import android.hardware.camera2.CameraManager; +import android.text.TextUtils; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.HeaderSetting; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumHeader; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.utils.SettingsFile; +import org.citra.citra_emu.utils.Log; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Objects; + +public final class SettingsFragmentPresenter { + private SettingsFragmentView mView; + + private String mMenuTag; + private String mGameID; + + private Settings mSettings; + private ArrayList mSettingsList; + + public SettingsFragmentPresenter(SettingsFragmentView view) { + mView = view; + } + + public void onCreate(String menuTag, String gameId) { + mGameID = gameId; + mMenuTag = menuTag; + } + + public void onViewCreated(Settings settings) { + setSettings(settings); + } + + /** + * If the screen is rotated, the Activity will forget the settings map. This fragment + * won't, though; so rather than have the Activity reload from disk, have the fragment pass + * the settings map back to the Activity. + */ + public void onAttach() { + if (mSettings != null) { + mView.passSettingsToActivity(mSettings); + } + } + + public void putSetting(Setting setting) { + mSettings.getSection(setting.getSection()).putSetting(setting); + } + + private StringSetting asStringSetting(Setting setting) { + if (setting == null) { + return null; + } + + StringSetting stringSetting = new StringSetting(setting.getKey(), setting.getSection(), setting.getValueAsString()); + putSetting(stringSetting); + return stringSetting; + } + + public void loadDefaultSettings() { + loadSettingsList(); + } + + public void setSettings(Settings settings) { + if (mSettingsList == null && settings != null) { + mSettings = settings; + + loadSettingsList(); + } else { + mView.getActivity().setTitle(R.string.preferences_settings); + mView.showSettingsList(mSettingsList); + } + } + + private void loadSettingsList() { + if (!TextUtils.isEmpty(mGameID)) { + mView.getActivity().setTitle("Game Settings: " + mGameID); + } + ArrayList sl = new ArrayList<>(); + + if (mMenuTag == null) { + return; + } + + switch (mMenuTag) { + case SettingsFile.FILE_NAME_CONFIG: + addConfigSettings(sl); + break; + case Settings.SECTION_PREMIUM: + addPremiumSettings(sl); + break; + case Settings.SECTION_CORE: + addGeneralSettings(sl); + break; + case Settings.SECTION_SYSTEM: + addSystemSettings(sl); + break; + case Settings.SECTION_CAMERA: + addCameraSettings(sl); + break; + case Settings.SECTION_CONTROLS: + addInputSettings(sl); + break; + case Settings.SECTION_RENDERER: + addGraphicsSettings(sl); + break; + case Settings.SECTION_AUDIO: + addAudioSettings(sl); + break; + case Settings.SECTION_DEBUG: + addDebugSettings(sl); + break; + default: + mView.showToastMessage("Unimplemented menu", false); + return; + } + + mSettingsList = sl; + mView.showSettingsList(mSettingsList); + } + + private void addConfigSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_settings); + + sl.add(new SubmenuSetting(null, null, R.string.preferences_premium, 0, Settings.SECTION_PREMIUM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_general, 0, Settings.SECTION_CORE)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_system, 0, Settings.SECTION_SYSTEM)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_camera, 0, Settings.SECTION_CAMERA)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_controls, 0, Settings.SECTION_CONTROLS)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_graphics, 0, Settings.SECTION_RENDERER)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_audio, 0, Settings.SECTION_AUDIO)); + sl.add(new SubmenuSetting(null, null, R.string.preferences_debug, 0, Settings.SECTION_DEBUG)); + } + + private void addPremiumSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_premium); + + SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM); + Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN); + + sl.add(new PremiumHeader()); + + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) { + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design, mView)); + } else { + // Pre-Android 10 does not support System Default + sl.add(new PremiumSingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNamesOld, R.array.designValuesOld, 0, design, mView)); + } + + //Setting textureFilterName = premiumSection.getSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME); + //sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_TEXTURE_FILTER_NAME, Settings.SECTION_PREMIUM, R.string.texture_filter_name, R.string.texture_filter_description, textureFilterNames, textureFilterNames, "none", textureFilterName)); + } + + private void addGeneralSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_general); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting frameLimitEnable = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED); + Setting frameLimitValue = rendererSection.getSetting(SettingsFile.KEY_FRAME_LIMIT); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_FRAME_LIMIT_ENABLED, Settings.SECTION_RENDERER, R.string.frame_limit_enable, R.string.frame_limit_enable_description, true, frameLimitEnable)); + sl.add(new SliderSetting(SettingsFile.KEY_FRAME_LIMIT, Settings.SECTION_RENDERER, R.string.frame_limit_slider, R.string.frame_limit_slider_description, 1, 200, "%", 100, frameLimitValue)); + } + + private void addSystemSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_system); + + SettingSection systemSection = mSettings.getSection(Settings.SECTION_SYSTEM); + Setting region = systemSection.getSetting(SettingsFile.KEY_REGION_VALUE); + Setting language = systemSection.getSetting(SettingsFile.KEY_LANGUAGE); + Setting systemClock = systemSection.getSetting(SettingsFile.KEY_INIT_CLOCK); + Setting dateTime = systemSection.getSetting(SettingsFile.KEY_INIT_TIME); + + sl.add(new SingleChoiceSetting(SettingsFile.KEY_REGION_VALUE, Settings.SECTION_SYSTEM, R.string.emulated_region, 0, R.array.regionNames, R.array.regionValues, -1, region)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_LANGUAGE, Settings.SECTION_SYSTEM, R.string.emulated_language, 0, R.array.languageNames, R.array.languageValues, 1, language)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_INIT_CLOCK, Settings.SECTION_SYSTEM, R.string.init_clock, R.string.init_clock_description, R.array.systemClockNames, R.array.systemClockValues, 0, systemClock)); + sl.add(new DateTimeSetting(SettingsFile.KEY_INIT_TIME, Settings.SECTION_SYSTEM, R.string.init_time, R.string.init_time_description, "2000-01-01 00:00:01", dateTime)); + } + + private void addCameraSettings(ArrayList sl) { + final Activity activity = mView.getActivity(); + activity.setTitle(R.string.preferences_camera); + + // Get the camera IDs + CameraManager cameraManager = (CameraManager) activity.getSystemService(Context.CAMERA_SERVICE); + ArrayList supportedCameraNameList = new ArrayList<>(); + ArrayList supportedCameraIdList = new ArrayList<>(); + if (cameraManager != null) { + try { + for (String id : cameraManager.getCameraIdList()) { + final CameraCharacteristics characteristics = cameraManager.getCameraCharacteristics(id); + if (Objects.requireNonNull(characteristics.get(CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL)) == CameraCharacteristics.INFO_SUPPORTED_HARDWARE_LEVEL_LEGACY) { + continue; // Legacy cameras cannot be used with the NDK + } + + supportedCameraIdList.add(id); + + final int facing = Objects.requireNonNull(characteristics.get(CameraCharacteristics.LENS_FACING)); + int stringId = R.string.camera_facing_external; + switch (facing) { + case CameraCharacteristics.LENS_FACING_FRONT: + stringId = R.string.camera_facing_front; + break; + case CameraCharacteristics.LENS_FACING_BACK: + stringId = R.string.camera_facing_back; + break; + case CameraCharacteristics.LENS_FACING_EXTERNAL: + stringId = R.string.camera_facing_external; + break; + } + supportedCameraNameList.add(String.format("%1$s (%2$s)", id, activity.getString(stringId))); + } + } catch (CameraAccessException e) { + Log.error("Couldn't retrieve camera list"); + e.printStackTrace(); + } + } + + // Create the names and values for display + ArrayList cameraDeviceNameList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceNames))); + cameraDeviceNameList.addAll(supportedCameraNameList); + ArrayList cameraDeviceValueList = new ArrayList<>(Arrays.asList(activity.getResources().getStringArray(R.array.cameraDeviceValues))); + cameraDeviceValueList.addAll(supportedCameraIdList); + + final String[] cameraDeviceNames = cameraDeviceNameList.toArray(new String[]{}); + final String[] cameraDeviceValues = cameraDeviceValueList.toArray(new String[]{}); + + final boolean haveCameraDevices = !supportedCameraIdList.isEmpty(); + + String[] imageSourceNames = activity.getResources().getStringArray(R.array.cameraImageSourceNames); + String[] imageSourceValues = activity.getResources().getStringArray(R.array.cameraImageSourceValues); + if (!haveCameraDevices) { + // Remove the last entry (ndk / Device Camera) + imageSourceNames = Arrays.copyOfRange(imageSourceNames, 0, imageSourceNames.length - 1); + imageSourceValues = Arrays.copyOfRange(imageSourceValues, 0, imageSourceValues.length - 1); + } + + final String defaultImageSource = haveCameraDevices ? "ndk" : "image"; + + SettingSection cameraSection = mSettings.getSection(Settings.SECTION_CAMERA); + + Setting innerCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_NAME); + Setting innerCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG)); + Setting innerCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_INNER_FLIP); + sl.add(new HeaderSetting(null, null, R.string.inner_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, innerCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_front", innerCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_INNER_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, innerCameraFlip)); + + Setting outerLeftCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME); + Setting outerLeftCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG)); + Setting outerLeftCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_left_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerLeftCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerLeftCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_LEFT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerLeftCameraFlip)); + + Setting outerRightCameraImageSource = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME); + Setting outerRightCameraConfig = asStringSetting(cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG)); + Setting outerRightCameraFlip = cameraSection.getSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP); + sl.add(new HeaderSetting(null, null, R.string.outer_right_camera, 0)); + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_NAME, Settings.SECTION_CAMERA, R.string.image_source, R.string.image_source_description, imageSourceNames, imageSourceValues, defaultImageSource, outerRightCameraImageSource)); + if (haveCameraDevices) + sl.add(new StringSingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_CONFIG, Settings.SECTION_CAMERA, R.string.camera_device, R.string.camera_device_description, cameraDeviceNames, cameraDeviceValues, "_back", outerRightCameraConfig)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_CAMERA_OUTER_RIGHT_FLIP, Settings.SECTION_CAMERA, R.string.image_flip, 0, R.array.cameraFlipNames, R.array.cameraFlipValues, 0, outerRightCameraFlip)); + } + + private void addInputSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_controls); + + SettingSection controlsSection = mSettings.getSection(Settings.SECTION_CONTROLS); + Setting buttonA = controlsSection.getSetting(SettingsFile.KEY_BUTTON_A); + Setting buttonB = controlsSection.getSetting(SettingsFile.KEY_BUTTON_B); + Setting buttonX = controlsSection.getSetting(SettingsFile.KEY_BUTTON_X); + Setting buttonY = controlsSection.getSetting(SettingsFile.KEY_BUTTON_Y); + Setting buttonSelect = controlsSection.getSetting(SettingsFile.KEY_BUTTON_SELECT); + Setting buttonStart = controlsSection.getSetting(SettingsFile.KEY_BUTTON_START); + Setting circlepadAxisVert = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL); + Setting circlepadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL); + Setting cstickAxisVert = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL); + Setting cstickAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL); + Setting dpadAxisVert = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL); + Setting dpadAxisHoriz = controlsSection.getSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL); + // Setting buttonUp = controlsSection.getSetting(SettingsFile.KEY_BUTTON_UP); + // Setting buttonDown = controlsSection.getSetting(SettingsFile.KEY_BUTTON_DOWN); + // Setting buttonLeft = controlsSection.getSetting(SettingsFile.KEY_BUTTON_LEFT); + // Setting buttonRight = controlsSection.getSetting(SettingsFile.KEY_BUTTON_RIGHT); + Setting buttonL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_L); + Setting buttonR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_R); + Setting buttonZL = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZL); + Setting buttonZR = controlsSection.getSetting(SettingsFile.KEY_BUTTON_ZR); + + sl.add(new HeaderSetting(null, null, R.string.generic_buttons, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_A, Settings.SECTION_CONTROLS, R.string.button_a, buttonA)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_B, Settings.SECTION_CONTROLS, R.string.button_b, buttonB)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_X, Settings.SECTION_CONTROLS, R.string.button_x, buttonX)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_Y, Settings.SECTION_CONTROLS, R.string.button_y, buttonY)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_SELECT, Settings.SECTION_CONTROLS, R.string.button_select, buttonSelect)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_START, Settings.SECTION_CONTROLS, R.string.button_start, buttonStart)); + + sl.add(new HeaderSetting(null, null, R.string.controller_circlepad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, circlepadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CIRCLEPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, circlepadAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_c, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, cstickAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_CSTICK_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, cstickAxisHoriz)); + + sl.add(new HeaderSetting(null, null, R.string.controller_dpad, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_VERTICAL, Settings.SECTION_CONTROLS, R.string.controller_axis_vertical, dpadAxisVert)); + sl.add(new InputBindingSetting(SettingsFile.KEY_DPAD_AXIS_HORIZONTAL, Settings.SECTION_CONTROLS, R.string.controller_axis_horizontal, dpadAxisHoriz)); + + // TODO(bunnei): Figure out what to do with these. Configuring is functional, but removing for MVP because they are confusing. + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_UP, Settings.SECTION_CONTROLS, R.string.generic_up, buttonUp)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_DOWN, Settings.SECTION_CONTROLS, R.string.generic_down, buttonDown)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_LEFT, Settings.SECTION_CONTROLS, R.string.generic_left, buttonLeft)); + // sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_RIGHT, Settings.SECTION_CONTROLS, R.string.generic_right, buttonRight)); + + sl.add(new HeaderSetting(null, null, R.string.controller_triggers, 0)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_L, Settings.SECTION_CONTROLS, R.string.button_l, buttonL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_R, Settings.SECTION_CONTROLS, R.string.button_r, buttonR)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZL, Settings.SECTION_CONTROLS, R.string.button_zl, buttonZL)); + sl.add(new InputBindingSetting(SettingsFile.KEY_BUTTON_ZR, Settings.SECTION_CONTROLS, R.string.button_zr, buttonZR)); + } + + private void addGraphicsSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_graphics); + + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting resolutionFactor = rendererSection.getSetting(SettingsFile.KEY_RESOLUTION_FACTOR); + Setting filterMode = rendererSection.getSetting(SettingsFile.KEY_FILTER_MODE); + Setting shadersAccurateMul = rendererSection.getSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL); + Setting render3dMode = rendererSection.getSetting(SettingsFile.KEY_RENDER_3D); + Setting factor3d = rendererSection.getSetting(SettingsFile.KEY_FACTOR_3D); + Setting useDiskShaderCache = rendererSection.getSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE); + SettingSection layoutSection = mSettings.getSection(Settings.SECTION_LAYOUT); + Setting cardboardScreenSize = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE); + Setting cardboardXShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT); + Setting cardboardYShift = layoutSection.getSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT); + SettingSection utilitySection = mSettings.getSection(Settings.SECTION_UTILITY); + Setting dumpTextures = utilitySection.getSetting(SettingsFile.KEY_DUMP_TEXTURES); + Setting customTextures = utilitySection.getSetting(SettingsFile.KEY_CUSTOM_TEXTURES); + //Setting preloadTextures = utilitySection.getSetting(SettingsFile.KEY_PRELOAD_TEXTURES); + + sl.add(new HeaderSetting(null, null, R.string.renderer, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_RESOLUTION_FACTOR, Settings.SECTION_RENDERER, R.string.internal_resolution, R.string.internal_resolution_description, 1, 4, "x", 1, resolutionFactor)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_FILTER_MODE, Settings.SECTION_RENDERER, R.string.linear_filtering, R.string.linear_filtering_description, true, filterMode)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_SHADERS_ACCURATE_MUL, Settings.SECTION_RENDERER, R.string.shaders_accurate_mul, R.string.shaders_accurate_mul_description, false, shadersAccurateMul)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_DISK_SHADER_CACHE, Settings.SECTION_RENDERER, R.string.use_disk_shader_cache, R.string.use_disk_shader_cache_description, true, useDiskShaderCache)); + + sl.add(new HeaderSetting(null, null, R.string.stereoscopy, 0)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_RENDER_3D, Settings.SECTION_RENDERER, R.string.render3d, 0, R.array.render3dModes, R.array.render3dValues, 0, render3dMode)); + sl.add(new SliderSetting(SettingsFile.KEY_FACTOR_3D, Settings.SECTION_RENDERER, R.string.factor3d, R.string.factor3d_description, 0, 100, "%", 0, factor3d)); + + sl.add(new HeaderSetting(null, null, R.string.cardboard_vr, 0)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_SCREEN_SIZE, Settings.SECTION_LAYOUT, R.string.cardboard_screen_size, R.string.cardboard_screen_size_description, 30, 100, "%", 85, cardboardScreenSize)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_X_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_x_shift, R.string.cardboard_x_shift_description, -100, 100, "%", 0, cardboardXShift)); + sl.add(new SliderSetting(SettingsFile.KEY_CARDBOARD_Y_SHIFT, Settings.SECTION_LAYOUT, R.string.cardboard_y_shift, R.string.cardboard_y_shift_description, -100, 100, "%", 0, cardboardYShift)); + + sl.add(new HeaderSetting(null, null, R.string.utility, 0)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_DUMP_TEXTURES, Settings.SECTION_UTILITY, R.string.dump_textures, R.string.dump_textures_description, false, dumpTextures)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_CUSTOM_TEXTURES, Settings.SECTION_UTILITY, R.string.custom_textures, R.string.custom_textures_description, false, customTextures)); + //Disabled until custom texture implementation gets rewrite, current one overloads RAM and crashes Citra. + //sl.add(new CheckBoxSetting(SettingsFile.KEY_PRELOAD_TEXTURES, Settings.SECTION_UTILITY, R.string.preload_textures, R.string.preload_textures_description, false, preloadTextures)); + } + + private void addAudioSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_audio); + + SettingSection audioSection = mSettings.getSection(Settings.SECTION_AUDIO); + Setting audioStretch = audioSection.getSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING); + Setting micInputType = audioSection.getSetting(SettingsFile.KEY_MIC_INPUT_TYPE); + + sl.add(new CheckBoxSetting(SettingsFile.KEY_ENABLE_AUDIO_STRETCHING, Settings.SECTION_AUDIO, R.string.audio_stretch, R.string.audio_stretch_description, true, audioStretch)); + sl.add(new SingleChoiceSetting(SettingsFile.KEY_MIC_INPUT_TYPE, Settings.SECTION_AUDIO, R.string.audio_input_type, 0, R.array.audioInputTypeNames, R.array.audioInputTypeValues, 1, micInputType)); + } + + private void addDebugSettings(ArrayList sl) { + mView.getActivity().setTitle(R.string.preferences_debug); + + SettingSection coreSection = mSettings.getSection(Settings.SECTION_CORE); + SettingSection rendererSection = mSettings.getSection(Settings.SECTION_RENDERER); + Setting useCpuJit = coreSection.getSetting(SettingsFile.KEY_CPU_JIT); + Setting hardwareRenderer = rendererSection.getSetting(SettingsFile.KEY_HW_RENDERER); + Setting hardwareShader = rendererSection.getSetting(SettingsFile.KEY_HW_SHADER); + Setting vsyncEnable = rendererSection.getSetting(SettingsFile.KEY_USE_VSYNC); + + sl.add(new HeaderSetting(null, null, R.string.debug_warning, 0)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_CPU_JIT, Settings.SECTION_CORE, R.string.cpu_jit, R.string.cpu_jit_description, true, useCpuJit, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_RENDERER, Settings.SECTION_RENDERER, R.string.hw_renderer, R.string.hw_renderer_description, true, hardwareRenderer, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_HW_SHADER, Settings.SECTION_RENDERER, R.string.hw_shaders, R.string.hw_shaders_description, true, hardwareShader, true, mView)); + sl.add(new CheckBoxSetting(SettingsFile.KEY_USE_VSYNC, Settings.SECTION_RENDERER, R.string.vsync, R.string.vsync_description, true, vsyncEnable)); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java new file mode 100644 index 000000000..c36eb55a7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentView.java @@ -0,0 +1,78 @@ +package org.citra.citra_emu.features.settings.ui; + +import androidx.fragment.app.FragmentActivity; + +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; + +import java.util.ArrayList; + +/** + * Abstraction for a screen showing a list of settings. Instances of + * this type of view will each display a layer of the setting hierarchy. + */ +public interface SettingsFragmentView { + /** + * Called by the containing Activity to notify the Fragment that an + * asynchronous load operation completed. + * + * @param settings The (possibly null) result of the ini load operation. + */ + void onSettingsFileLoaded(Settings settings); + + /** + * Pass a settings HashMap to the containing activity, so that it can + * share the HashMap with other SettingsFragments; useful so that rotations + * do not require an additional load operation. + * + * @param settings An ArrayList containing all the settings HashMaps. + */ + void passSettingsToActivity(Settings settings); + + /** + * Pass an ArrayList to the View so that it can be displayed on screen. + * + * @param settingsList The result of converting the HashMap to an ArrayList + */ + void showSettingsList(ArrayList settingsList); + + /** + * Called by the containing Activity when an asynchronous load operation fails. + * Instructs the Fragment to load the settings screen with defaults selected. + */ + void loadDefaultSettings(); + + /** + * @return The Fragment's containing activity. + */ + FragmentActivity getActivity(); + + /** + * Tell the Fragment to tell the containing Activity to show a new + * Fragment containing a submenu of settings. + * + * @param menuKey Identifier for the settings group that should be shown. + */ + void loadSubMenu(String menuKey); + + /** + * Tell the Fragment to tell the containing activity to display a toast message. + * + * @param message Text to be shown in the Toast + * @param is_long Whether this should be a long Toast or short one. + */ + void showToastMessage(String message, boolean is_long); + + /** + * Have the fragment add a setting to the HashMap. + * + * @param setting The (possibly previously missing) new setting. + */ + void putSetting(Setting setting); + + /** + * Have the fragment tell the containing Activity that a setting was modified. + */ + void onSettingChanged(); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java new file mode 100644 index 000000000..67bde5709 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFrameLayout.java @@ -0,0 +1,48 @@ +package org.citra.citra_emu.features.settings.ui; + +import android.content.Context; +import android.util.AttributeSet; +import android.widget.FrameLayout; + +/** + * FrameLayout subclass with few Properties added to simplify animations. + * Don't remove the methods appearing as unused, in order not to break the menu animations + */ +public final class SettingsFrameLayout extends FrameLayout { + private float mVisibleness = 1.0f; + + public SettingsFrameLayout(Context context) { + super(context); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public SettingsFrameLayout(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + } + + public float getYFraction() { + return getY() / getHeight(); + } + + public void setYFraction(float yFraction) { + final int height = getHeight(); + setY((height > 0) ? (yFraction * height) : -9999); + } + + public float getVisibleness() { + return mVisibleness; + } + + public void setVisibleness(float visibleness) { + setScaleX(visibleness); + setScaleY(visibleness); + setAlpha(visibleness); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java new file mode 100644 index 000000000..d914f7d0b --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/CheckBoxSettingViewHolder.java @@ -0,0 +1,54 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.CheckBox; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.CheckBoxSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class CheckBoxSettingViewHolder extends SettingViewHolder { + private CheckBoxSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private CheckBox mCheckbox; + + public CheckBoxSettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + mCheckbox = root.findViewById(R.id.checkbox); + } + + @Override + public void bind(SettingsItem item) { + mItem = (CheckBoxSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setText(""); + mTextSettingDescription.setVisibility(View.GONE); + } + + mCheckbox.setChecked(mItem.isChecked()); + } + + @Override + public void onClick(View clicked) { + mCheckbox.toggle(); + + getAdapter().onBooleanClick(mItem, getAdapterPosition(), mCheckbox.isChecked()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java new file mode 100644 index 000000000..09ea93010 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/DateTimeViewHolder.java @@ -0,0 +1,47 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.DateTimeSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.utils.Log; + +public final class DateTimeViewHolder extends SettingViewHolder { + private DateTimeSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public DateTimeViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + Log.error("test " + mTextSettingName); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + Log.error("test " + mTextSettingDescription); + } + + @Override + public void bind(SettingsItem item) { + mItem = (DateTimeSetting) item; + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onDateTimeClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java new file mode 100644 index 000000000..baf80ed76 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/HeaderViewHolder.java @@ -0,0 +1,32 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class HeaderViewHolder extends SettingViewHolder { + private TextView mHeaderName; + + public HeaderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + itemView.setOnClickListener(null); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_header_name); + } + + @Override + public void bind(SettingsItem item) { + mHeaderName.setText(item.getNameId()); + } + + @Override + public void onClick(View clicked) { + // no-op + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java new file mode 100644 index 000000000..7d95c250a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/InputBindingSettingViewHolder.java @@ -0,0 +1,55 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.Context; +import android.content.SharedPreferences; +import android.preference.PreferenceManager; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.InputBindingSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class InputBindingSettingViewHolder extends SettingViewHolder { + private InputBindingSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + private Context mContext; + + public InputBindingSettingViewHolder(View itemView, SettingsAdapter adapter, Context context) { + super(itemView, adapter); + + mContext = context; + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + SharedPreferences sharedPreferences = PreferenceManager.getDefaultSharedPreferences(mContext); + + mItem = (InputBindingSetting) item; + + mTextSettingName.setText(item.getNameId()); + + String key = sharedPreferences.getString(mItem.getKey(), ""); + if (key != null && !key.isEmpty()) { + mTextSettingDescription.setText(key); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onInputBindingClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java new file mode 100644 index 000000000..be0853ff0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; +import org.citra.citra_emu.ui.main.MainActivity; + +public final class PremiumViewHolder extends SettingViewHolder { + private TextView mHeaderName; + private TextView mTextDescription; + private SettingsFragmentView mView; + + public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) { + super(itemView, adapter); + mView = view; + itemView.setOnClickListener(this); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_setting_name); + mTextDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + updateText(); + } + + @Override + public void onClick(View clicked) { + if (MainActivity.isPremiumActive()) { + return; + } + + // Invoke billing flow if Premium is not already active, then refresh the UI to indicate + // the purchase has completed. + MainActivity.invokePremiumBilling(() -> updateText()); + } + + /** + * Update the text shown to the user, based on whether Premium is active + */ + private void updateText() { + if (MainActivity.isPremiumActive()) { + mHeaderName.setText(R.string.premium_settings_welcome); + mTextDescription.setText(R.string.premium_settings_welcome_description); + } else { + mHeaderName.setText(R.string.premium_settings_upsell); + mTextDescription.setText(R.string.premium_settings_upsell_description); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java new file mode 100644 index 000000000..2643ea121 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SettingViewHolder.java @@ -0,0 +1,49 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; + +import androidx.recyclerview.widget.RecyclerView; + +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public abstract class SettingViewHolder extends RecyclerView.ViewHolder implements View.OnClickListener { + private SettingsAdapter mAdapter; + + public SettingViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView); + + mAdapter = adapter; + + itemView.setOnClickListener(this); + + findViews(itemView); + } + + protected SettingsAdapter getAdapter() { + return mAdapter; + } + + /** + * Gets handles to all this ViewHolder's child views using their XML-defined identifiers. + * + * @param root The newly inflated top-level view. + */ + protected abstract void findViews(View root); + + /** + * Called by the adapter to set this ViewHolder's child views to display the list item + * it must now represent. + * + * @param item The list item that should be represented by this ViewHolder. + */ + public abstract void bind(SettingsItem item); + + /** + * Called when this ViewHolder's view is clicked on. Implementations should usually pass + * this event up to the adapter. + * + * @param clicked The view that was clicked on. + */ + public abstract void onClick(View clicked); +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java new file mode 100644 index 000000000..a175af9f8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SingleChoiceViewHolder.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.content.res.Resources; +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.PremiumSingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; +import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SingleChoiceViewHolder extends SettingViewHolder { + private SettingsItem mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SingleChoiceViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = item; + + mTextSettingName.setText(item.getNameId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + } else if (item instanceof SingleChoiceSetting) { + SingleChoiceSetting setting = (SingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else if (item instanceof PremiumSingleChoiceSetting) { + PremiumSingleChoiceSetting setting = (PremiumSingleChoiceSetting) item; + int selected = setting.getSelectedValue(); + Resources resMgr = mTextSettingDescription.getContext().getResources(); + String[] choices = resMgr.getStringArray(setting.getChoicesId()); + int[] values = resMgr.getIntArray(setting.getValuesId()); + for (int i = 0; i < values.length; ++i) { + if (values[i] == selected) { + mTextSettingDescription.setText(choices[i]); + } + } + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + int position = getAdapterPosition(); + if (mItem instanceof SingleChoiceSetting) { + getAdapter().onSingleChoiceClick((SingleChoiceSetting) mItem, position); + } else if (mItem instanceof PremiumSingleChoiceSetting) { + getAdapter().onSingleChoiceClick((PremiumSingleChoiceSetting) mItem, position); + } else if (mItem instanceof StringSingleChoiceSetting) { + getAdapter().onStringSingleChoiceClick((StringSingleChoiceSetting) mItem, position); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java new file mode 100644 index 000000000..3dd048a29 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SliderViewHolder.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SliderSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SliderViewHolder extends SettingViewHolder { + private SliderSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SliderViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SliderSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSliderClick(mItem, getAdapterPosition()); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java new file mode 100644 index 000000000..cb8c3e92a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/SubmenuViewHolder.java @@ -0,0 +1,45 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; + +public final class SubmenuViewHolder extends SettingViewHolder { + private SubmenuSetting mItem; + + private TextView mTextSettingName; + private TextView mTextSettingDescription; + + public SubmenuViewHolder(View itemView, SettingsAdapter adapter) { + super(itemView, adapter); + } + + @Override + protected void findViews(View root) { + mTextSettingName = root.findViewById(R.id.text_setting_name); + mTextSettingDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + mItem = (SubmenuSetting) item; + + mTextSettingName.setText(item.getNameId()); + + if (item.getDescriptionId() > 0) { + mTextSettingDescription.setText(item.getDescriptionId()); + mTextSettingDescription.setVisibility(View.VISIBLE); + } else { + mTextSettingDescription.setVisibility(View.GONE); + } + } + + @Override + public void onClick(View clicked) { + getAdapter().onSubmenuClick(mItem); + } +} \ No newline at end of file diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java new file mode 100644 index 000000000..8ae6b70d7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/utils/SettingsFile.java @@ -0,0 +1,341 @@ +package org.citra.citra_emu.features.settings.utils; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.CitraApplication; +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.FloatSetting; +import org.citra.citra_emu.features.settings.model.IntSetting; +import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.SettingSection; +import org.citra.citra_emu.features.settings.model.Settings; +import org.citra.citra_emu.features.settings.model.StringSetting; +import org.citra.citra_emu.features.settings.ui.SettingsActivityView; +import org.citra.citra_emu.utils.BiMap; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.Log; +import org.ini4j.Wini; + +import java.io.BufferedReader; +import java.io.File; +import java.io.FileNotFoundException; +import java.io.FileReader; +import java.io.IOException; +import java.util.HashMap; +import java.util.Set; +import java.util.TreeMap; +import java.util.TreeSet; + +/** + * Contains static methods for interacting with .ini files in which settings are stored. + */ +public final class SettingsFile { + public static final String FILE_NAME_CONFIG = "config"; + + public static final String KEY_CPU_JIT = "use_cpu_jit"; + + public static final String KEY_DESIGN = "design"; + + public static final String KEY_PREMIUM = "premium"; + + public static final String KEY_HW_RENDERER = "use_hw_renderer"; + public static final String KEY_HW_SHADER = "use_hw_shader"; + public static final String KEY_SHADERS_ACCURATE_MUL = "shaders_accurate_mul"; + public static final String KEY_USE_SHADER_JIT = "use_shader_jit"; + public static final String KEY_USE_DISK_SHADER_CACHE = "use_disk_shader_cache"; + public static final String KEY_USE_VSYNC = "use_vsync_new"; + public static final String KEY_RESOLUTION_FACTOR = "resolution_factor"; + public static final String KEY_FRAME_LIMIT_ENABLED = "use_frame_limit"; + public static final String KEY_FRAME_LIMIT = "frame_limit"; + public static final String KEY_BACKGROUND_RED = "bg_red"; + public static final String KEY_BACKGROUND_BLUE = "bg_blue"; + public static final String KEY_BACKGROUND_GREEN = "bg_green"; + public static final String KEY_RENDER_3D = "render_3d"; + public static final String KEY_FACTOR_3D = "factor_3d"; + public static final String KEY_PP_SHADER_NAME = "pp_shader_name"; + public static final String KEY_FILTER_MODE = "filter_mode"; + public static final String KEY_TEXTURE_FILTER_NAME = "texture_filter_name"; + public static final String KEY_USE_ASYNCHRONOUS_GPU_EMULATION = "use_asynchronous_gpu_emulation"; + + public static final String KEY_LAYOUT_OPTION = "layout_option"; + public static final String KEY_SWAP_SCREEN = "swap_screen"; + public static final String KEY_CARDBOARD_SCREEN_SIZE = "cardboard_screen_size"; + public static final String KEY_CARDBOARD_X_SHIFT = "cardboard_x_shift"; + public static final String KEY_CARDBOARD_Y_SHIFT = "cardboard_y_shift"; + + public static final String KEY_DUMP_TEXTURES = "dump_textures"; + public static final String KEY_CUSTOM_TEXTURES = "custom_textures"; + public static final String KEY_PRELOAD_TEXTURES = "preload_textures"; + + public static final String KEY_AUDIO_OUTPUT_ENGINE = "output_engine"; + public static final String KEY_ENABLE_AUDIO_STRETCHING = "enable_audio_stretching"; + public static final String KEY_VOLUME = "volume"; + public static final String KEY_MIC_INPUT_TYPE = "mic_input_type"; + + public static final String KEY_USE_VIRTUAL_SD = "use_virtual_sd"; + + public static final String KEY_IS_NEW_3DS = "is_new_3ds"; + public static final String KEY_REGION_VALUE = "region_value"; + public static final String KEY_LANGUAGE = "language"; + + public static final String KEY_INIT_CLOCK = "init_clock"; + public static final String KEY_INIT_TIME = "init_time"; + + public static final String KEY_BUTTON_A = "button_a"; + public static final String KEY_BUTTON_B = "button_b"; + public static final String KEY_BUTTON_X = "button_x"; + public static final String KEY_BUTTON_Y = "button_y"; + public static final String KEY_BUTTON_SELECT = "button_select"; + public static final String KEY_BUTTON_START = "button_start"; + public static final String KEY_BUTTON_UP = "button_up"; + public static final String KEY_BUTTON_DOWN = "button_down"; + public static final String KEY_BUTTON_LEFT = "button_left"; + public static final String KEY_BUTTON_RIGHT = "button_right"; + public static final String KEY_BUTTON_L = "button_l"; + public static final String KEY_BUTTON_R = "button_r"; + public static final String KEY_BUTTON_ZL = "button_zl"; + public static final String KEY_BUTTON_ZR = "button_zr"; + public static final String KEY_CIRCLEPAD_AXIS_VERTICAL = "circlepad_axis_vertical"; + public static final String KEY_CIRCLEPAD_AXIS_HORIZONTAL = "circlepad_axis_horizontal"; + public static final String KEY_CSTICK_AXIS_VERTICAL = "cstick_axis_vertical"; + public static final String KEY_CSTICK_AXIS_HORIZONTAL = "cstick_axis_horizontal"; + public static final String KEY_DPAD_AXIS_VERTICAL = "dpad_axis_vertical"; + public static final String KEY_DPAD_AXIS_HORIZONTAL = "dpad_axis_horizontal"; + public static final String KEY_CIRCLEPAD_UP = "circlepad_up"; + public static final String KEY_CIRCLEPAD_DOWN = "circlepad_down"; + public static final String KEY_CIRCLEPAD_LEFT = "circlepad_left"; + public static final String KEY_CIRCLEPAD_RIGHT = "circlepad_right"; + public static final String KEY_CSTICK_UP = "cstick_up"; + public static final String KEY_CSTICK_DOWN = "cstick_down"; + public static final String KEY_CSTICK_LEFT = "cstick_left"; + public static final String KEY_CSTICK_RIGHT = "cstick_right"; + + public static final String KEY_CAMERA_OUTER_RIGHT_NAME = "camera_outer_right_name"; + public static final String KEY_CAMERA_OUTER_RIGHT_CONFIG = "camera_outer_right_config"; + public static final String KEY_CAMERA_OUTER_RIGHT_FLIP = "camera_outer_right_flip"; + public static final String KEY_CAMERA_OUTER_LEFT_NAME = "camera_outer_left_name"; + public static final String KEY_CAMERA_OUTER_LEFT_CONFIG = "camera_outer_left_config"; + public static final String KEY_CAMERA_OUTER_LEFT_FLIP = "camera_outer_left_flip"; + public static final String KEY_CAMERA_INNER_NAME = "camera_inner_name"; + public static final String KEY_CAMERA_INNER_CONFIG = "camera_inner_config"; + public static final String KEY_CAMERA_INNER_FLIP = "camera_inner_flip"; + + public static final String KEY_LOG_FILTER = "log_filter"; + + private static BiMap sectionsMap = new BiMap<>(); + + static { + //TODO: Add members to sectionsMap when game-specific settings are added + } + + + private SettingsFile() { + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of Settings, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param ini The ini file to load the settings from + * @param isCustomGame + * @param view The current view. + * @return An Observable that emits a HashMap of the file's contents, then completes. + */ + static HashMap readFile(final File ini, boolean isCustomGame, SettingsActivityView view) { + HashMap sections = new Settings.SettingsSectionMap(); + + BufferedReader reader = null; + + try { + reader = new BufferedReader(new FileReader(ini)); + + SettingSection current = null; + for (String line; (line = reader.readLine()) != null; ) { + if (line.startsWith("[") && line.endsWith("]")) { + current = sectionFromLine(line, isCustomGame); + sections.put(current.getName(), current); + } else if ((current != null)) { + Setting setting = settingFromLine(current, line); + if (setting != null) { + current.putSetting(setting); + } + } + } + } catch (FileNotFoundException e) { + Log.error("[SettingsFile] File not found: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } catch (IOException e) { + Log.error("[SettingsFile] Error reading from: " + ini.getAbsolutePath() + e.getMessage()); + if (view != null) + view.onSettingsFileNotFound(); + } finally { + if (reader != null) { + try { + reader.close(); + } catch (IOException e) { + Log.error("[SettingsFile] Error closing: " + ini.getAbsolutePath() + e.getMessage()); + } + } + } + + return sections; + } + + public static HashMap readFile(final String fileName, SettingsActivityView view) { + return readFile(getSettingsFile(fileName), false, view); + } + + /** + * Reads a given .ini file from disk and returns it as a HashMap of SettingSections, themselves + * effectively a HashMap of key/value settings. If unsuccessful, outputs an error telling why it + * failed. + * + * @param gameId the id of the game to load it's settings. + * @param view The current view. + */ + public static HashMap readCustomGameSettings(final String gameId, SettingsActivityView view) { + return readFile(getCustomGameSettingsFile(gameId), true, view); + } + + /** + * Saves a Settings HashMap to a given .ini file on disk. If unsuccessful, outputs an error + * telling why it failed. + * + * @param fileName The target filename without a path or extension. + * @param sections The HashMap containing the Settings we want to serialize. + * @param view The current view. + */ + public static void saveFile(final String fileName, TreeMap sections, + SettingsActivityView view) { + File ini = getSettingsFile(fileName); + + try { + Wini writer = new Wini(ini); + + Set keySet = sections.keySet(); + for (String key : keySet) { + SettingSection section = sections.get(key); + writeSection(writer, section); + } + writer.store(); + } catch (IOException e) { + Log.error("[SettingsFile] File not found: " + fileName + ".ini: " + e.getMessage()); + view.showToastMessage(CitraApplication.getAppContext().getString(R.string.error_saving, fileName, e.getMessage()), false); + } + } + + + public static void saveCustomGameSettings(final String gameId, final HashMap sections) { + Set sortedSections = new TreeSet<>(sections.keySet()); + + for (String sectionKey : sortedSections) { + SettingSection section = sections.get(sectionKey); + + HashMap settings = section.getSettings(); + Set sortedKeySet = new TreeSet<>(settings.keySet()); + + for (String settingKey : sortedKeySet) { + Setting setting = settings.get(settingKey); + NativeLibrary.SetUserSetting(gameId, mapSectionNameFromIni(section.getName()), setting.getKey(), setting.getValueAsString()); + } + } + } + + private static String mapSectionNameFromIni(String generalSectionName) { + if (sectionsMap.getForward(generalSectionName) != null) { + return sectionsMap.getForward(generalSectionName); + } + + return generalSectionName; + } + + private static String mapSectionNameToIni(String generalSectionName) { + if (sectionsMap.getBackward(generalSectionName) != null) { + return sectionsMap.getBackward(generalSectionName); + } + + return generalSectionName; + } + + @NonNull + private static File getSettingsFile(String fileName) { + return new File( + DirectoryInitialization.getUserDirectory() + "/config/" + fileName + ".ini"); + } + + private static File getCustomGameSettingsFile(String gameId) { + return new File(DirectoryInitialization.getUserDirectory() + "/GameSettings/" + gameId + ".ini"); + } + + private static SettingSection sectionFromLine(String line, boolean isCustomGame) { + String sectionName = line.substring(1, line.length() - 1); + if (isCustomGame) { + sectionName = mapSectionNameToIni(sectionName); + } + return new SettingSection(sectionName); + } + + /** + * For a line of text, determines what type of data is being represented, and returns + * a Setting object containing this data. + * + * @param current The section currently being parsed by the consuming method. + * @param line The line of text being parsed. + * @return A typed Setting containing the key/value contained in the line. + */ + private static Setting settingFromLine(SettingSection current, String line) { + String[] splitLine = line.split("="); + + if (splitLine.length != 2) { + Log.warning("Skipping invalid config line \"" + line + "\""); + return null; + } + + String key = splitLine[0].trim(); + String value = splitLine[1].trim(); + + if (value.isEmpty()) { + Log.warning("Skipping null value in config line \"" + line + "\""); + return null; + } + + try { + int valueAsInt = Integer.parseInt(value); + + return new IntSetting(key, current.getName(), valueAsInt); + } catch (NumberFormatException ex) { + } + + try { + float valueAsFloat = Float.parseFloat(value); + + return new FloatSetting(key, current.getName(), valueAsFloat); + } catch (NumberFormatException ex) { + } + + return new StringSetting(key, current.getName(), value); + } + + /** + * Writes the contents of a Section HashMap to disk. + * + * @param parser A Wini pointed at a file on disk. + * @param section A section containing settings to be written to the file. + */ + private static void writeSection(Wini parser, SettingSection section) { + // Write the section header. + String header = section.getName(); + + // Write this section's values. + HashMap settings = section.getSettings(); + Set keySet = settings.keySet(); + + for (String key : keySet) { + Setting setting = settings.get(key); + parser.put(header, setting.getKey(), setting.getValueAsString()); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java new file mode 100644 index 000000000..c18ecd4c3 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/CustomFilePickerFragment.java @@ -0,0 +1,120 @@ +package org.citra.citra_emu.fragments; + +import android.net.Uri; +import android.os.Bundle; +import android.os.Environment; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.widget.TextView; + +import androidx.annotation.NonNull; +import androidx.appcompat.widget.Toolbar; +import androidx.core.content.FileProvider; + +import com.nononsenseapps.filepicker.FilePickerFragment; + +import org.citra.citra_emu.R; + +import java.io.File; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +public class CustomFilePickerFragment extends FilePickerFragment { + private static String ALL_FILES = "*"; + private int mTitle; + private static List extensions = Collections.singletonList(ALL_FILES); + + @NonNull + @Override + public Uri toUri(@NonNull final File file) { + return FileProvider + .getUriForFile(getContext(), + getContext().getApplicationContext().getPackageName() + ".filesprovider", + file); + } + + @Override + public void onActivityCreated(Bundle savedInstanceState) { + super.onActivityCreated(savedInstanceState); + + if (mode == MODE_DIR) { + TextView ok = getActivity().findViewById(R.id.nnf_button_ok); + ok.setText(R.string.select_dir); + + TextView cancel = getActivity().findViewById(R.id.nnf_button_cancel); + cancel.setVisibility(View.GONE); + } + } + + @Override + protected View inflateRootView(LayoutInflater inflater, ViewGroup container) { + View view = super.inflateRootView(inflater, container); + if (mTitle != 0) { + Toolbar toolbar = view.findViewById(com.nononsenseapps.filepicker.R.id.nnf_picker_toolbar); + ViewGroup parent = (ViewGroup) toolbar.getParent(); + int index = parent.indexOfChild(toolbar); + View newToolbar = inflater.inflate(R.layout.filepicker_toolbar, toolbar, false); + TextView title = newToolbar.findViewById(R.id.filepicker_title); + title.setText(mTitle); + parent.removeView(toolbar); + parent.addView(newToolbar, index); + } + return view; + } + + public void setTitle(int title) { + mTitle = title; + } + + public void setAllowedExtensions(String allowedExtensions) { + if (allowedExtensions == null) + return; + + extensions = Arrays.asList(allowedExtensions.split(",")); + } + + @Override + protected boolean isItemVisible(@NonNull final File file) { + // Some users jump to the conclusion that Dolphin isn't able to detect their + // files if the files don't show up in the file picker when mode == MODE_DIR. + // To avoid this, show files even when the user needs to select a directory. + return (showHiddenItems || !file.isHidden()) && + (file.isDirectory() || extensions.contains(ALL_FILES) || + extensions.contains(fileExtension(file.getName()).toLowerCase())); + } + + @Override + public boolean isCheckable(@NonNull final File file) { + // We need to make a small correction to the isCheckable logic due to + // overriding isItemVisible to show files when mode == MODE_DIR. + // AbstractFilePickerFragment always treats files as checkable when + // allowExistingFile == true, but we don't want files to be checkable when mode == MODE_DIR. + return super.isCheckable(file) && !(mode == MODE_DIR && file.isFile()); + } + + @Override + public void goUp() { + if (Environment.getExternalStorageDirectory().getPath().equals(mCurrentPath.getPath())) { + goToDir(new File("/storage/")); + return; + } + if (mCurrentPath.equals(new File("/storage/"))){ + return; + } + super.goUp(); + } + + @Override + public void onClickDir(@NonNull View view, @NonNull DirViewHolder viewHolder) { + if(viewHolder.file.equals(new File("/storage/emulated/"))) + viewHolder.file = new File("/storage/emulated/0/"); + super.onClickDir(view, viewHolder); + } + + private static String fileExtension(@NonNull String filename) { + int i = filename.lastIndexOf('.'); + return i < 0 ? "" : filename.substring(i + 1); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java new file mode 100644 index 000000000..cdb40d6f8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/fragments/EmulationFragment.java @@ -0,0 +1,380 @@ +package org.citra.citra_emu.fragments; + +import android.content.Context; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.os.Bundle; +import android.os.Handler; +import android.preference.PreferenceManager; +import android.view.Choreographer; +import android.view.LayoutInflater; +import android.view.Surface; +import android.view.SurfaceHolder; +import android.view.SurfaceView; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.TextView; +import android.widget.Toast; + +import androidx.annotation.NonNull; +import androidx.fragment.app.Fragment; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.R; +import org.citra.citra_emu.activities.EmulationActivity; +import org.citra.citra_emu.overlay.InputOverlay; +import org.citra.citra_emu.utils.DirectoryInitialization; +import org.citra.citra_emu.utils.DirectoryInitialization.DirectoryInitializationState; +import org.citra.citra_emu.utils.DirectoryStateReceiver; +import org.citra.citra_emu.utils.EmulationMenuSettings; +import org.citra.citra_emu.utils.Log; + +public final class EmulationFragment extends Fragment implements SurfaceHolder.Callback, Choreographer.FrameCallback { + private static final String KEY_GAMEPATH = "gamepath"; + + private static final Handler perfStatsUpdateHandler = new Handler(); + + private SharedPreferences mPreferences; + + private InputOverlay mInputOverlay; + + private EmulationState mEmulationState; + + private DirectoryStateReceiver directoryStateReceiver; + + private EmulationActivity activity; + + private TextView mPerfStats; + + private Runnable perfStatsUpdater; + + public static EmulationFragment newInstance(String gamePath) { + Bundle args = new Bundle(); + args.putString(KEY_GAMEPATH, gamePath); + + EmulationFragment fragment = new EmulationFragment(); + fragment.setArguments(args); + return fragment; + } + + @Override + public void onAttach(@NonNull Context context) { + super.onAttach(context); + + if (context instanceof EmulationActivity) { + activity = (EmulationActivity) context; + NativeLibrary.setEmulationActivity((EmulationActivity) context); + } else { + throw new IllegalStateException("EmulationFragment must have EmulationActivity parent"); + } + } + + /** + * Initialize anything that doesn't depend on the layout / views in here. + */ + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + // So this fragment doesn't restart on configuration changes; i.e. rotation. + setRetainInstance(true); + + mPreferences = PreferenceManager.getDefaultSharedPreferences(getActivity()); + + String gamePath = getArguments().getString(KEY_GAMEPATH); + mEmulationState = new EmulationState(gamePath); + } + + /** + * Initialize the UI and start emulation in here. + */ + @Override + public View onCreateView(LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) { + View contents = inflater.inflate(R.layout.fragment_emulation, container, false); + + SurfaceView surfaceView = contents.findViewById(R.id.surface_emulation); + surfaceView.getHolder().addCallback(this); + + mInputOverlay = contents.findViewById(R.id.surface_input_overlay); + mPerfStats = contents.findViewById(R.id.show_fps_text); + mPerfStats.setTextColor(Color.YELLOW); + + Button doneButton = contents.findViewById(R.id.done_control_config); + if (doneButton != null) { + doneButton.setOnClickListener(v -> stopConfiguringControls()); + } + + // Show/hide the "Show FPS" overlay + updateShowFpsOverlay(); + + // The new Surface created here will get passed to the native code via onSurfaceChanged. + return contents; + } + + @Override + public void onResume() { + super.onResume(); + Choreographer.getInstance().postFrameCallback(this); + if (DirectoryInitialization.areCitraDirectoriesReady()) { + mEmulationState.run(activity.isActivityRecreated()); + } else { + setupCitraDirectoriesThenStartEmulation(); + } + } + + @Override + public void onPause() { + if (directoryStateReceiver != null) { + LocalBroadcastManager.getInstance(getActivity()).unregisterReceiver(directoryStateReceiver); + directoryStateReceiver = null; + } + + if (mEmulationState.isRunning()) { + mEmulationState.pause(); + } + + Choreographer.getInstance().removeFrameCallback(this); + super.onPause(); + } + + @Override + public void onDetach() { + NativeLibrary.clearEmulationActivity(); + super.onDetach(); + } + + private void setupCitraDirectoriesThenStartEmulation() { + IntentFilter statusIntentFilter = new IntentFilter( + DirectoryInitialization.BROADCAST_ACTION); + + directoryStateReceiver = + new DirectoryStateReceiver(directoryInitializationState -> + { + if (directoryInitializationState == + DirectoryInitializationState.CITRA_DIRECTORIES_INITIALIZED) { + mEmulationState.run(activity.isActivityRecreated()); + } else if (directoryInitializationState == + DirectoryInitializationState.EXTERNAL_STORAGE_PERMISSION_NEEDED) { + Toast.makeText(getContext(), R.string.write_permission_needed, Toast.LENGTH_SHORT) + .show(); + } else if (directoryInitializationState == + DirectoryInitializationState.CANT_FIND_EXTERNAL_STORAGE) { + Toast.makeText(getContext(), R.string.external_storage_not_mounted, + Toast.LENGTH_SHORT) + .show(); + } + }); + + // Registers the DirectoryStateReceiver and its intent filters + LocalBroadcastManager.getInstance(getActivity()).registerReceiver( + directoryStateReceiver, + statusIntentFilter); + DirectoryInitialization.start(getActivity()); + } + + public void refreshInputOverlay() { + mInputOverlay.refreshControls(); + } + + public void resetInputOverlay() { + // Reset button scale + SharedPreferences.Editor editor = mPreferences.edit(); + editor.putInt("controlScale", 50); + editor.apply(); + + mInputOverlay.resetButtonPlacement(); + } + + public void updateShowFpsOverlay() { + if (true) { + final int SYSTEM_FPS = 0; + final int FPS = 1; + final int FRAMETIME = 2; + final int SPEED = 3; + + perfStatsUpdater = () -> + { + final double[] perfStats = NativeLibrary.GetPerfStats(); + if (perfStats[FPS] > 0) { + mPerfStats.setText(String.format("FPS: %d Speed: %d%%", (int) (perfStats[FPS]), + (int) (perfStats[SPEED] * 100.0))); + } + + perfStatsUpdateHandler.postDelayed(perfStatsUpdater, 3000); + }; + perfStatsUpdateHandler.post(perfStatsUpdater); + + mPerfStats.setVisibility(View.VISIBLE); + } else { + if (perfStatsUpdater != null) { + perfStatsUpdateHandler.removeCallbacks(perfStatsUpdater); + } + + mPerfStats.setVisibility(View.GONE); + } + } + + @Override + public void surfaceCreated(SurfaceHolder holder) { + // We purposely don't do anything here. + // All work is done in surfaceChanged, which we are guaranteed to get even for surface creation. + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + Log.debug("[EmulationFragment] Surface changed. Resolution: " + width + "x" + height); + mEmulationState.newSurface(holder.getSurface()); + } + + @Override + public void surfaceDestroyed(SurfaceHolder holder) { + mEmulationState.clearSurface(); + } + + @Override + public void doFrame(long frameTimeNanos) { + Choreographer.getInstance().postFrameCallback(this); + NativeLibrary.DoFrame(); + } + + public void stopEmulation() { + mEmulationState.stop(); + } + + public void startConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.VISIBLE); + mInputOverlay.setIsInEditMode(true); + } + + public void stopConfiguringControls() { + getView().findViewById(R.id.done_control_config).setVisibility(View.GONE); + mInputOverlay.setIsInEditMode(false); + } + + public boolean isConfiguringControls() { + return mInputOverlay.isInEditMode(); + } + + private static class EmulationState { + private final String mGamePath; + private State state; + private Surface mSurface; + private boolean mRunWhenSurfaceIsValid; + + EmulationState(String gamePath) { + mGamePath = gamePath; + // Starting state is stopped. + state = State.STOPPED; + } + + public synchronized boolean isStopped() { + return state == State.STOPPED; + } + + // Getters for the current state + + public synchronized boolean isPaused() { + return state == State.PAUSED; + } + + public synchronized boolean isRunning() { + return state == State.RUNNING; + } + + public synchronized void stop() { + if (state != State.STOPPED) { + Log.debug("[EmulationFragment] Stopping emulation."); + state = State.STOPPED; + NativeLibrary.StopEmulation(); + } else { + Log.warning("[EmulationFragment] Stop called while already stopped."); + } + } + + // State changing methods + + public synchronized void pause() { + if (state != State.PAUSED) { + state = State.PAUSED; + Log.debug("[EmulationFragment] Pausing emulation."); + + // Release the surface before pausing, since emulation has to be running for that. + NativeLibrary.SurfaceDestroyed(); + NativeLibrary.PauseEmulation(); + } else { + Log.warning("[EmulationFragment] Pause called while already paused."); + } + } + + public synchronized void run(boolean isActivityRecreated) { + if (isActivityRecreated) { + if (NativeLibrary.IsRunning()) { + state = State.PAUSED; + } + } else { + Log.debug("[EmulationFragment] activity resumed or fresh start"); + } + + // If the surface is set, run now. Otherwise, wait for it to get set. + if (mSurface != null) { + runWithValidSurface(); + } else { + mRunWhenSurfaceIsValid = true; + } + } + + // Surface callbacks + public synchronized void newSurface(Surface surface) { + mSurface = surface; + if (mRunWhenSurfaceIsValid) { + runWithValidSurface(); + } + } + + public synchronized void clearSurface() { + if (mSurface == null) { + Log.warning("[EmulationFragment] clearSurface called, but surface already null."); + } else { + mSurface = null; + Log.debug("[EmulationFragment] Surface destroyed."); + + if (state == State.RUNNING) { + NativeLibrary.SurfaceDestroyed(); + state = State.PAUSED; + } else if (state == State.PAUSED) { + Log.warning("[EmulationFragment] Surface cleared while emulation paused."); + } else { + Log.warning("[EmulationFragment] Surface cleared while emulation stopped."); + } + } + } + + private void runWithValidSurface() { + mRunWhenSurfaceIsValid = false; + if (state == State.STOPPED) { + NativeLibrary.SurfaceChanged(mSurface); + Thread mEmulationThread = new Thread(() -> + { + Log.debug("[EmulationFragment] Starting emulation thread."); + NativeLibrary.Run(mGamePath); + }, "NativeEmulation"); + mEmulationThread.start(); + + } else if (state == State.PAUSED) { + Log.debug("[EmulationFragment] Resuming emulation."); + NativeLibrary.SurfaceChanged(mSurface); + NativeLibrary.UnPauseEmulation(); + } else { + Log.debug("[EmulationFragment] Bug, run called while already running."); + } + state = State.RUNNING; + } + + private enum State { + STOPPED, RUNNING, PAUSED + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java new file mode 100644 index 000000000..a4ffc59c7 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/Game.java @@ -0,0 +1,76 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.database.Cursor; + +import java.nio.file.Paths; + +public final class Game { + private String mTitle; + private String mDescription; + private String mPath; + private String mGameId; + private String mCompany; + private String mRegions; + + public Game(String title, String description, String regions, String path, + String gameId, String company) { + mTitle = title; + mDescription = description; + mRegions = regions; + mPath = path; + mGameId = gameId; + mCompany = company; + } + + public static ContentValues asContentValues(String title, String description, String regions, String path, String gameId, String company) { + ContentValues values = new ContentValues(); + + if (gameId.isEmpty()) { + // Homebrew, etc. may not have a game ID, use filename as a unique identifier + gameId = Paths.get(path).getFileName().toString(); + } + + values.put(GameDatabase.KEY_GAME_TITLE, title); + values.put(GameDatabase.KEY_GAME_DESCRIPTION, description); + values.put(GameDatabase.KEY_GAME_REGIONS, regions); + values.put(GameDatabase.KEY_GAME_PATH, path); + values.put(GameDatabase.KEY_GAME_ID, gameId); + values.put(GameDatabase.KEY_GAME_COMPANY, company); + + return values; + } + + public static Game fromCursor(Cursor cursor) { + return new Game(cursor.getString(GameDatabase.GAME_COLUMN_TITLE), + cursor.getString(GameDatabase.GAME_COLUMN_DESCRIPTION), + cursor.getString(GameDatabase.GAME_COLUMN_REGIONS), + cursor.getString(GameDatabase.GAME_COLUMN_PATH), + cursor.getString(GameDatabase.GAME_COLUMN_GAME_ID), + cursor.getString(GameDatabase.GAME_COLUMN_COMPANY)); + } + + public String getTitle() { + return mTitle; + } + + public String getDescription() { + return mDescription; + } + + public String getCompany() { + return mCompany; + } + + public String getRegions() { + return mRegions; + } + + public String getPath() { + return mPath; + } + + public String getGameId() { + return mGameId; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java new file mode 100644 index 000000000..8232d0489 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameDatabase.java @@ -0,0 +1,276 @@ +package org.citra.citra_emu.model; + +import android.content.ContentValues; +import android.content.Context; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.database.sqlite.SQLiteOpenHelper; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.utils.Log; + +import java.io.File; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import rx.Observable; + +/** + * A helper class that provides several utilities simplifying interaction with + * the SQLite database. + */ +public final class GameDatabase extends SQLiteOpenHelper { + public static final int COLUMN_DB_ID = 0; + public static final int GAME_COLUMN_PATH = 1; + public static final int GAME_COLUMN_TITLE = 2; + public static final int GAME_COLUMN_DESCRIPTION = 3; + public static final int GAME_COLUMN_REGIONS = 4; + public static final int GAME_COLUMN_GAME_ID = 5; + public static final int GAME_COLUMN_COMPANY = 6; + public static final int FOLDER_COLUMN_PATH = 1; + public static final String KEY_DB_ID = "_id"; + public static final String KEY_GAME_PATH = "path"; + public static final String KEY_GAME_TITLE = "title"; + public static final String KEY_GAME_DESCRIPTION = "description"; + public static final String KEY_GAME_REGIONS = "regions"; + public static final String KEY_GAME_ID = "game_id"; + public static final String KEY_GAME_COMPANY = "company"; + public static final String KEY_FOLDER_PATH = "path"; + public static final String TABLE_NAME_FOLDERS = "folders"; + public static final String TABLE_NAME_GAMES = "games"; + private static final int DB_VERSION = 2; + private static final String TYPE_PRIMARY = " INTEGER PRIMARY KEY"; + private static final String TYPE_INTEGER = " INTEGER"; + private static final String TYPE_STRING = " TEXT"; + + private static final String CONSTRAINT_UNIQUE = " UNIQUE"; + + private static final String SEPARATOR = ", "; + + private static final String SQL_CREATE_GAMES = "CREATE TABLE " + TABLE_NAME_GAMES + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_GAME_PATH + TYPE_STRING + SEPARATOR + + KEY_GAME_TITLE + TYPE_STRING + SEPARATOR + + KEY_GAME_DESCRIPTION + TYPE_STRING + SEPARATOR + + KEY_GAME_REGIONS + TYPE_STRING + SEPARATOR + + KEY_GAME_ID + TYPE_STRING + SEPARATOR + + KEY_GAME_COMPANY + TYPE_STRING + ")"; + + private static final String SQL_CREATE_FOLDERS = "CREATE TABLE " + TABLE_NAME_FOLDERS + "(" + + KEY_DB_ID + TYPE_PRIMARY + SEPARATOR + + KEY_FOLDER_PATH + TYPE_STRING + CONSTRAINT_UNIQUE + ")"; + + private static final String SQL_DELETE_FOLDERS = "DROP TABLE IF EXISTS " + TABLE_NAME_FOLDERS; + private static final String SQL_DELETE_GAMES = "DROP TABLE IF EXISTS " + TABLE_NAME_GAMES; + + public GameDatabase(Context context) { + // Superclass constructor builds a database or uses an existing one. + super(context, "games.db", null, DB_VERSION); + } + + @Override + public void onCreate(SQLiteDatabase database) { + Log.debug("[GameDatabase] GameDatabase - Creating database..."); + + execSqlAndLog(database, SQL_CREATE_GAMES); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + } + + @Override + public void onDowngrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.verbose("[GameDatabase] Downgrades not supporting, clearing databases.."); + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + @Override + public void onUpgrade(SQLiteDatabase database, int oldVersion, int newVersion) { + Log.info("[GameDatabase] Upgrading database from schema version " + oldVersion + " to " + + newVersion); + + // Delete all the games + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void resetDatabase(SQLiteDatabase database) { + execSqlAndLog(database, SQL_DELETE_FOLDERS); + execSqlAndLog(database, SQL_CREATE_FOLDERS); + + execSqlAndLog(database, SQL_DELETE_GAMES); + execSqlAndLog(database, SQL_CREATE_GAMES); + } + + public void scanLibrary(SQLiteDatabase database) { + // Before scanning known folders, go through the game table and remove any entries for which the file itself is missing. + Cursor fileCursor = database.query(TABLE_NAME_GAMES, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of games is irrelevant. + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + fileCursor.moveToPosition(-1); + + while (fileCursor.moveToNext()) { + String gamePath = fileCursor.getString(GAME_COLUMN_PATH); + File game = new File(gamePath); + + if (!game.exists()) { + Log.error("[GameDatabase] Game file no longer exists. Removing from the library: " + + gamePath); + database.delete(TABLE_NAME_GAMES, + KEY_DB_ID + " = ?", + new String[]{Long.toString(fileCursor.getLong(COLUMN_DB_ID))}); + } + } + + // Get a cursor listing all the folders the user has added to the library. + Cursor folderCursor = database.query(TABLE_NAME_FOLDERS, + null, // Get all columns. + null, // Get all rows. + null, + null, // No grouping. + null, + null); // Order of folders is irrelevant. + + Set allowedExtensions = new HashSet(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + + // Possibly overly defensive, but ensures that moveToNext() does not skip a row. + folderCursor.moveToPosition(-1); + + // Iterate through all results of the DB query (i.e. all folders in the library.) + while (folderCursor.moveToNext()) { + String folderPath = folderCursor.getString(FOLDER_COLUMN_PATH); + + File folder = new File(folderPath); + // If the folder is empty because it no longer exists, remove it from the library. + if (!folder.exists()) { + Log.error( + "[GameDatabase] Folder no longer exists. Removing from the library: " + folderPath); + database.delete(TABLE_NAME_FOLDERS, + KEY_DB_ID + " = ?", + new String[]{Long.toString(folderCursor.getLong(COLUMN_DB_ID))}); + } + + addGamesRecursive(database, folder, allowedExtensions, 3); + } + + fileCursor.close(); + folderCursor.close(); + + database.close(); + } + + private static void addGamesRecursive(SQLiteDatabase database, File parent, Set allowedExtensions, int depth) { + if (depth <= 0) { + return; + } + + File[] children = parent.listFiles(); + if (children != null) { + for (File file : children) { + if (file.isHidden()) { + continue; + } + + if (file.isDirectory()) { + Set newExtensions = new HashSet<>(Arrays.asList( + ".xci", ".nsp", ".nca", ".nro")); + addGamesRecursive(database, file, newExtensions, depth - 1); + } else { + String filePath = file.getPath(); + + int extensionStart = filePath.lastIndexOf('.'); + if (extensionStart > 0) { + String fileExtension = filePath.substring(extensionStart); + + // Check that the file has an extension we care about before trying to read out of it. + if (allowedExtensions.contains(fileExtension.toLowerCase())) { + attemptToAddGame(database, filePath); + } + } + } + } + } + } + + private static void attemptToAddGame(SQLiteDatabase database, String filePath) { + String name = NativeLibrary.GetTitle(filePath); + + // If the game's title field is empty, use the filename. + if (name.isEmpty()) { + name = filePath.substring(filePath.lastIndexOf("/") + 1); + } + + String gameId = NativeLibrary.GetGameId(filePath); + + // If the game's ID field is empty, use the filename without extension. + if (gameId.isEmpty()) { + gameId = filePath.substring(filePath.lastIndexOf("/") + 1, + filePath.lastIndexOf(".")); + } + + ContentValues game = Game.asContentValues(name, + NativeLibrary.GetDescription(filePath).replace("\n", " "), + NativeLibrary.GetRegions(filePath), + filePath, + gameId, + NativeLibrary.GetCompany(filePath)); + + // Try to update an existing game first. + int rowsMatched = database.update(TABLE_NAME_GAMES, // Which table to update. + game, + // The values to fill the row with. + KEY_GAME_ID + " = ?", + // The WHERE clause used to find the right row. + new String[]{game.getAsString( + KEY_GAME_ID)}); // The ? in WHERE clause is replaced with this, + // which is provided as an array because there + // could potentially be more than one argument. + + // If update fails, insert a new game instead. + if (rowsMatched == 0) { + Log.verbose("[GameDatabase] Adding game: " + game.getAsString(KEY_GAME_TITLE)); + database.insert(TABLE_NAME_GAMES, null, game); + } else { + Log.verbose("[GameDatabase] Updated game: " + game.getAsString(KEY_GAME_TITLE)); + } + } + + public Observable getGames() { + return Observable.create(subscriber -> + { + Log.info("[GameDatabase] Reading games list..."); + + SQLiteDatabase database = getReadableDatabase(); + Cursor resultCursor = database.query( + TABLE_NAME_GAMES, + null, + null, + null, + null, + null, + KEY_GAME_TITLE + " ASC" + ); + + // Pass the result cursor to the consumer. + subscriber.onNext(resultCursor); + + // Tell the consumer we're done; it will unsubscribe implicitly. + subscriber.onCompleted(); + }); + } + + private void execSqlAndLog(SQLiteDatabase database, String sql) { + Log.verbose("[GameDatabase] Executing SQL: " + sql); + database.execSQL(sql); + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java new file mode 100644 index 000000000..33b289fc4 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/model/GameProvider.java @@ -0,0 +1,138 @@ +package org.citra.citra_emu.model; + +import android.content.ContentProvider; +import android.content.ContentValues; +import android.database.Cursor; +import android.database.sqlite.SQLiteDatabase; +import android.net.Uri; + +import androidx.annotation.NonNull; + +import org.citra.citra_emu.BuildConfig; +import org.citra.citra_emu.utils.Log; + +/** + * Provides an interface allowing Activities to interact with the SQLite database. + * CRUD methods in this class can be called by Activities using getContentResolver(). + */ +public final class GameProvider extends ContentProvider { + public static final String REFRESH_LIBRARY = "refresh"; + public static final String RESET_LIBRARY = "reset"; + + public static final String AUTHORITY = "content://" + BuildConfig.APPLICATION_ID + ".provider"; + public static final Uri URI_FOLDER = + Uri.parse(AUTHORITY + "/" + GameDatabase.TABLE_NAME_FOLDERS + "/"); + public static final Uri URI_REFRESH = Uri.parse(AUTHORITY + "/" + REFRESH_LIBRARY + "/"); + public static final Uri URI_RESET = Uri.parse(AUTHORITY + "/" + RESET_LIBRARY + "/"); + + public static final String MIME_TYPE_FOLDER = "vnd.android.cursor.item/vnd.dolphin.folder"; + public static final String MIME_TYPE_GAME = "vnd.android.cursor.item/vnd.dolphin.game"; + + + private GameDatabase mDbHelper; + + @Override + public boolean onCreate() { + Log.info("[GameProvider] Creating Content Provider..."); + + mDbHelper = new GameDatabase(getContext()); + + return true; + } + + @Override + public Cursor query(@NonNull Uri uri, String[] projection, String selection, + String[] selectionArgs, String sortOrder) { + Log.info("[GameProvider] Querying URI: " + uri); + + SQLiteDatabase db = mDbHelper.getReadableDatabase(); + + String table = uri.getLastPathSegment(); + + if (table == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + Cursor cursor = db.query(table, projection, selection, selectionArgs, null, null, sortOrder); + cursor.setNotificationUri(getContext().getContentResolver(), uri); + + return cursor; + } + + @Override + public String getType(@NonNull Uri uri) { + Log.verbose("[GameProvider] Getting MIME type for URI: " + uri); + String lastSegment = uri.getLastPathSegment(); + + if (lastSegment == null) { + Log.error("[GameProvider] Badly formatted URI: " + uri); + return null; + } + + if (lastSegment.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + return MIME_TYPE_FOLDER; + } else if (lastSegment.equals(GameDatabase.TABLE_NAME_GAMES)) { + return MIME_TYPE_GAME; + } + + Log.error("[GameProvider] Unknown MIME type for URI: " + uri); + return null; + } + + @Override + public Uri insert(@NonNull Uri uri, ContentValues values) { + Log.info("[GameProvider] Inserting row at URI: " + uri); + + SQLiteDatabase database = mDbHelper.getWritableDatabase(); + String table = uri.getLastPathSegment(); + + if (table != null) { + if (table.equals(RESET_LIBRARY)) { + mDbHelper.resetDatabase(database); + return uri; + } + if (table.equals(REFRESH_LIBRARY)) { + Log.info( + "[GameProvider] URI specified table REFRESH_LIBRARY. No insertion necessary; refreshing library contents..."); + mDbHelper.scanLibrary(database); + return uri; + } + + long id = database.insertWithOnConflict(table, null, values, SQLiteDatabase.CONFLICT_IGNORE); + + // If insertion was successful... + if (id > 0) { + // If we just added a folder, add its contents to the game list. + if (table.equals(GameDatabase.TABLE_NAME_FOLDERS)) { + mDbHelper.scanLibrary(database); + } + + // Notify the UI that its contents should be refreshed. + getContext().getContentResolver().notifyChange(uri, null); + uri = Uri.withAppendedPath(uri, Long.toString(id)); + } else { + Log.error("[GameProvider] Row already exists: " + uri + " id: " + id); + } + } else { + Log.error("[GameProvider] Badly formatted URI: " + uri); + } + + database.close(); + + return uri; + } + + @Override + public int delete(@NonNull Uri uri, String selection, String[] selectionArgs) { + Log.error("[GameProvider] Delete operations unsupported. URI: " + uri); + return 0; + } + + @Override + public int update(@NonNull Uri uri, ContentValues values, String selection, + String[] selectionArgs) { + Log.error("[GameProvider] Update operations unsupported. URI: " + uri); + return 0; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java new file mode 100644 index 000000000..cdb2f7666 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/overlay/InputOverlay.java @@ -0,0 +1,878 @@ +/** + * Copyright 2013 Dolphin Emulator Project + * Licensed under GPLv2+ + * Refer to the license.txt file included. + */ + +package org.citra.citra_emu.overlay; + +import android.app.Activity; +import android.content.Context; +import android.content.SharedPreferences; +import android.content.res.Configuration; +import android.content.res.Resources; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Rect; +import android.graphics.drawable.Drawable; +import android.preference.PreferenceManager; +import android.util.AttributeSet; +import android.util.DisplayMetrics; +import android.view.Display; +import android.view.MotionEvent; +import android.view.SurfaceView; +import android.view.View; +import android.view.View.OnTouchListener; + +import org.citra.citra_emu.NativeLibrary; +import org.citra.citra_emu.NativeLibrary.ButtonState; +import org.citra.citra_emu.NativeLibrary.ButtonType; +import org.citra.citra_emu.R; +import org.citra.citra_emu.utils.EmulationMenuSettings; + +import java.util.HashSet; +import java.util.Set; + +/** + * Draws the interactive input overlay on top of the + * {@link SurfaceView} that is rendering emulation. + */ +public final class InputOverlay extends SurfaceView implements OnTouchListener { + private final Set overlayButtons = new HashSet<>(); + private final Set overlayDpads = new HashSet<>(); + private final Set 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/TwoPaneOnBackPressedCallback.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java new file mode 100644 index 000000000..d07fe30d8 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/TwoPaneOnBackPressedCallback.java @@ -0,0 +1,37 @@ +package org.citra.citra_emu.ui; + +import android.view.View; + +import androidx.activity.OnBackPressedCallback; +import androidx.annotation.NonNull; +import androidx.slidingpanelayout.widget.SlidingPaneLayout; + +public class TwoPaneOnBackPressedCallback extends OnBackPressedCallback + implements SlidingPaneLayout.PanelSlideListener { + private final SlidingPaneLayout mSlidingPaneLayout; + + public TwoPaneOnBackPressedCallback(@NonNull SlidingPaneLayout slidingPaneLayout) { + super(slidingPaneLayout.isSlideable() && slidingPaneLayout.isOpen()); + mSlidingPaneLayout = slidingPaneLayout; + slidingPaneLayout.addPanelSlideListener(this); + } + + @Override + public void handleOnBackPressed() { + mSlidingPaneLayout.close(); + } + + @Override + public void onPanelSlide(@NonNull View panel, float slideOffset) { + } + + @Override + public void onPanelOpened(@NonNull View panel) { + setEnabled(true); + } + + @Override + public void onPanelClosed(@NonNull View panel) { + setEnabled(false); + } +} 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..4ba419a48 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -0,0 +1,267 @@ +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.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("xci", "nsp", "cci", "3ds", + "cxi", "app", "3dsx", "cia", + "rar", "zip", "7z", "torrent", + "tar", "gz", "nro")); + 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) { + 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..31c415779 --- /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_IMMUTABLE); + + 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/res/animator/settings_enter.xml b/src/android/app/src/main/res/animator/settings_enter.xml new file mode 100644 index 000000000..3c216a054 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_exit.xml b/src/android/app/src/main/res/animator/settings_exit.xml new file mode 100644 index 000000000..a233b6757 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_exit.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/settings_pop_enter.xml b/src/android/app/src/main/res/animator/settings_pop_enter.xml new file mode 100644 index 000000000..080bc27c4 --- /dev/null +++ b/src/android/app/src/main/res/animator/settings_pop_enter.xml @@ -0,0 +1,28 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/animator/setttings_pop_exit.xml b/src/android/app/src/main/res/animator/setttings_pop_exit.xml new file mode 100644 index 000000000..4fccbcca2 --- /dev/null +++ b/src/android/app/src/main/res/animator/setttings_pop_exit.xml @@ -0,0 +1,27 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a.png b/src/android/app/src/main/res/drawable-hdpi/button_a.png new file mode 100644 index 000000000..f96a2061e Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png new file mode 100644 index 000000000..785a258ee Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b.png b/src/android/app/src/main/res/drawable-hdpi/button_b.png new file mode 100644 index 000000000..b15d2b549 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png new file mode 100644 index 000000000..b11d5fcee Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l.png b/src/android/app/src/main/res/drawable-hdpi/button_l.png new file mode 100644 index 000000000..e19469a7b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png new file mode 100644 index 000000000..280857f64 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r.png b/src/android/app/src/main/res/drawable-hdpi/button_r.png new file mode 100644 index 000000000..f72cdc1dc Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png new file mode 100644 index 000000000..c47d34253 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select.png b/src/android/app/src/main/res/drawable-hdpi/button_select.png new file mode 100644 index 000000000..6961b88d2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png new file mode 100644 index 000000000..8ee471419 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start.png b/src/android/app/src/main/res/drawable-hdpi/button_start.png new file mode 100644 index 000000000..72856cf47 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png new file mode 100644 index 000000000..f96cd3359 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x.png b/src/android/app/src/main/res/drawable-hdpi/button_x.png new file mode 100644 index 000000000..1a0fd1924 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png new file mode 100644 index 000000000..089cb3af1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y.png b/src/android/app/src/main/res/drawable-hdpi/button_y.png new file mode 100644 index 000000000..bc22680c4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png new file mode 100644 index 000000000..6e9e89ec9 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl.png b/src/android/app/src/main/res/drawable-hdpi/button_zl.png new file mode 100644 index 000000000..dd5d4d5b3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png new file mode 100644 index 000000000..8cd395f3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr.png b/src/android/app/src/main/res/drawable-hdpi/button_zr.png new file mode 100644 index 000000000..728fcf4d1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png new file mode 100644 index 000000000..121877610 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad.png b/src/android/app/src/main/res/drawable-hdpi/dpad.png new file mode 100644 index 000000000..921b3902d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..a8ffbb48a Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..ceb994a6d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png new file mode 100644 index 000000000..8c00d8c34 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png new file mode 100644 index 000000000..90085252b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png new file mode 100644 index 000000000..7dd45a405 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png new file mode 100644 index 000000000..2e7837020 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..2282f1a3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c.png b/src/android/app/src/main/res/drawable-hdpi/stick_c.png new file mode 100644 index 000000000..d4c1d6c97 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png new file mode 100644 index 000000000..c8d14c029 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png new file mode 100644 index 000000000..8263d4b8d Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main.png b/src/android/app/src/main/res/drawable-hdpi/stick_main.png new file mode 100644 index 000000000..ae6d025a5 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png new file mode 100644 index 000000000..ca469c6a7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png new file mode 100644 index 000000000..9b5445edc Binary files /dev/null and b/src/android/app/src/main/res/drawable-hdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png new file mode 100644 index 000000000..c6dc232b4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png new file mode 100644 index 000000000..1e428dfe3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png new file mode 100644 index 000000000..4dfb62596 Binary files /dev/null and b/src/android/app/src/main/res/drawable-mdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png new file mode 100644 index 000000000..cc986c8ac Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png new file mode 100644 index 000000000..ee688b09f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png new file mode 100644 index 000000000..6b678d22c Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png new file mode 100644 index 000000000..bc9dc0beb Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-hdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png new file mode 100644 index 000000000..f61d84961 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png new file mode 100644 index 000000000..05847c34b Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png new file mode 100644 index 000000000..87bac27df Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-mdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png new file mode 100644 index 000000000..1eccbe68d Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png new file mode 100644 index 000000000..ffa1d200e Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png new file mode 100644 index 000000000..23a5cec51 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png new file mode 100644 index 000000000..9ca7975bb Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png new file mode 100644 index 000000000..fc3c434b0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png new file mode 100644 index 000000000..013600d1f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png new file mode 100644 index 000000000..2a0f1568f Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png new file mode 100644 index 000000000..23706188b Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png new file mode 100644 index 000000000..b4d1b92b7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png new file mode 100644 index 000000000..166bd052d Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png new file mode 100644 index 000000000..8d357b228 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png new file mode 100644 index 000000000..32eb4faff Binary files /dev/null and b/src/android/app/src/main/res/drawable-night-xxxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-night/no_icon.png b/src/android/app/src/main/res/drawable-night/no_icon.png new file mode 100644 index 000000000..9a3969709 Binary files /dev/null and b/src/android/app/src/main/res/drawable-night/no_icon.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a.png b/src/android/app/src/main/res/drawable-xhdpi/button_a.png new file mode 100644 index 000000000..4e20f2b0e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png new file mode 100644 index 000000000..f18edd07e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b.png b/src/android/app/src/main/res/drawable-xhdpi/button_b.png new file mode 100644 index 000000000..deb83a09d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png new file mode 100644 index 000000000..f583be028 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l.png b/src/android/app/src/main/res/drawable-xhdpi/button_l.png new file mode 100644 index 000000000..d24039fbf Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png new file mode 100644 index 000000000..378ac8751 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r.png b/src/android/app/src/main/res/drawable-xhdpi/button_r.png new file mode 100644 index 000000000..7b01c043e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png new file mode 100644 index 000000000..9b3e3e75a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select.png b/src/android/app/src/main/res/drawable-xhdpi/button_select.png new file mode 100644 index 000000000..57abf5666 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png new file mode 100644 index 000000000..29eda72af Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start.png b/src/android/app/src/main/res/drawable-xhdpi/button_start.png new file mode 100644 index 000000000..f9cf0d667 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png new file mode 100644 index 000000000..4d690fa7e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x.png b/src/android/app/src/main/res/drawable-xhdpi/button_x.png new file mode 100644 index 000000000..93a2ee997 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png new file mode 100644 index 000000000..6bbd39646 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y.png b/src/android/app/src/main/res/drawable-xhdpi/button_y.png new file mode 100644 index 000000000..d979e98e0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png new file mode 100644 index 000000000..a6c9bdb54 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png new file mode 100644 index 000000000..f94474fea Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png new file mode 100644 index 000000000..8f7d5ab7a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png new file mode 100644 index 000000000..a76658351 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png new file mode 100644 index 000000000..bbe4e64ce Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad.png b/src/android/app/src/main/res/drawable-xhdpi/dpad.png new file mode 100644 index 000000000..94ae84405 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..d6ccb2c4f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..2bba7749e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png new file mode 100644 index 000000000..839869401 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png new file mode 100644 index 000000000..02bc3d75a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png new file mode 100644 index 000000000..ac4b19ff4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png new file mode 100644 index 000000000..8cff45f84 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..5e2787ba3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png new file mode 100644 index 000000000..7819f220a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png new file mode 100644 index 000000000..a111c2ac7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png new file mode 100644 index 000000000..774c54292 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png new file mode 100644 index 000000000..3f80cdf6c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png new file mode 100644 index 000000000..2a7675ef7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png new file mode 100644 index 000000000..ca1672caf Binary files /dev/null and b/src/android/app/src/main/res/drawable-xhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png new file mode 100644 index 000000000..999b4c01e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png new file mode 100644 index 000000000..bb4de9bd9 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png new file mode 100644 index 000000000..8ed042e7e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png new file mode 100644 index 000000000..86f5d535e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png new file mode 100644 index 000000000..9572c66f8 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png new file mode 100644 index 000000000..64bedc326 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png new file mode 100644 index 000000000..abbcadede Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png new file mode 100644 index 000000000..07421767f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png new file mode 100644 index 000000000..42c3b7c43 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png new file mode 100644 index 000000000..0d1e56f6a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png new file mode 100644 index 000000000..4e9585bb4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png new file mode 100644 index 000000000..8c089e237 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png new file mode 100644 index 000000000..0500f964f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png new file mode 100644 index 000000000..56db5843d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png new file mode 100644 index 000000000..53c5ca084 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png new file mode 100644 index 000000000..5d91cbfb0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png new file mode 100644 index 000000000..f8ce9a0c6 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png new file mode 100644 index 000000000..981c8b0c8 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png new file mode 100644 index 000000000..82065e126 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png new file mode 100644 index 000000000..b30b2e799 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png new file mode 100644 index 000000000..36b7ea183 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..3715e1c11 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..fb0d7fc5c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png new file mode 100644 index 000000000..e6812f0d4 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png new file mode 100644 index 000000000..05f429614 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png new file mode 100644 index 000000000..63f162e52 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png new file mode 100644 index 000000000..0b9049f46 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png new file mode 100644 index 000000000..06cef9de3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/ic_stat_notification_logo.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png new file mode 100644 index 000000000..e950c5b15 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png new file mode 100644 index 000000000..3ac88ed9b Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png new file mode 100644 index 000000000..a3491c80f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png new file mode 100644 index 000000000..16ca58c0f Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png new file mode 100644 index 000000000..e7fe0c2d5 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png new file mode 100644 index 000000000..8c47b2ba3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png new file mode 100644 index 000000000..e364fae1e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png new file mode 100644 index 000000000..08d65cc99 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_a_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png new file mode 100644 index 000000000..faae9b6f7 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png new file mode 100644 index 000000000..669780f28 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_b_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png new file mode 100644 index 000000000..888b147de Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png new file mode 100644 index 000000000..605493e3e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_l_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png new file mode 100644 index 000000000..90a93af8d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png new file mode 100644 index 000000000..4500cd2be Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_r_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png new file mode 100644 index 000000000..b18b2fd59 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png new file mode 100644 index 000000000..53ed400e0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_select_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png new file mode 100644 index 000000000..c55e56852 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png new file mode 100644 index 000000000..1507cc365 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_start_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png new file mode 100644 index 000000000..7ef2b883e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png new file mode 100644 index 000000000..f3f11ede2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_x_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png new file mode 100644 index 000000000..4ce679c69 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png new file mode 100644 index 000000000..926f5e269 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_y_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png new file mode 100644 index 000000000..7faf8db3b Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png new file mode 100644 index 000000000..cc56a749c Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zl_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png new file mode 100644 index 000000000..ed1b6b683 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png new file mode 100644 index 000000000..892fa74f1 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/button_zr_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png new file mode 100644 index 000000000..6272f39e6 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png new file mode 100644 index 000000000..0cccd3a30 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_one_direction.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png new file mode 100644 index 000000000..18a99ad2d Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/dpad_pressed_two_directions.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png new file mode 100644 index 000000000..69ae32dc3 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_cia_install.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png new file mode 100644 index 000000000..c85074c60 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_folder.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png new file mode 100644 index 000000000..6f1550a10 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_premium.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png new file mode 100644 index 000000000..2827a1777 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/ic_settings_core.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png new file mode 100644 index 000000000..88e09b8a0 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png new file mode 100644 index 000000000..edc920e8e Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png new file mode 100644 index 000000000..a8b693494 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_c_range.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png new file mode 100644 index 000000000..d157edca2 Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png new file mode 100644 index 000000000..2ac2440be Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_pressed.png differ diff --git a/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png new file mode 100644 index 000000000..71e67e02a Binary files /dev/null and b/src/android/app/src/main/res/drawable-xxxhdpi/stick_main_range.png differ diff --git a/src/android/app/src/main/res/drawable/gamelist_divider.xml b/src/android/app/src/main/res/drawable/gamelist_divider.xml new file mode 100644 index 000000000..7da9dccce --- /dev/null +++ b/src/android/app/src/main/res/drawable/gamelist_divider.xml @@ -0,0 +1,11 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/drawable/ic_add.xml b/src/android/app/src/main/res/drawable/ic_add.xml new file mode 100644 index 000000000..bdd99f48d --- /dev/null +++ b/src/android/app/src/main/res/drawable/ic_add.xml @@ -0,0 +1,9 @@ + + + diff --git a/src/android/app/src/main/res/drawable/no_icon.png b/src/android/app/src/main/res/drawable/no_icon.png new file mode 100644 index 000000000..1ce8fdc76 Binary files /dev/null and b/src/android/app/src/main/res/drawable/no_icon.png differ diff --git a/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml new file mode 100644 index 000000000..9bcf883e1 --- /dev/null +++ b/src/android/app/src/main/res/layout-ldrtl/list_item_cheat.xml @@ -0,0 +1,38 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_cheats.xml b/src/android/app/src/main/res/layout/activity_cheats.xml new file mode 100644 index 000000000..b9414ab6d --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_cheats.xml @@ -0,0 +1,22 @@ + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_emulation.xml b/src/android/app/src/main/res/layout/activity_emulation.xml new file mode 100644 index 000000000..7d7f36925 --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_emulation.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/activity_main.xml b/src/android/app/src/main/res/layout/activity_main.xml new file mode 100644 index 000000000..cea0922a7 --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_main.xml @@ -0,0 +1,27 @@ + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/activity_settings.xml b/src/android/app/src/main/res/layout/activity_settings.xml new file mode 100644 index 000000000..11b91c45f --- /dev/null +++ b/src/android/app/src/main/res/layout/activity_settings.xml @@ -0,0 +1,5 @@ + + diff --git a/src/android/app/src/main/res/layout/card_game.xml b/src/android/app/src/main/res/layout/card_game.xml new file mode 100644 index 000000000..217f02d34 --- /dev/null +++ b/src/android/app/src/main/res/layout/card_game.xml @@ -0,0 +1,81 @@ + + + + + + + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_checkbox.xml b/src/android/app/src/main/res/layout/dialog_checkbox.xml new file mode 100644 index 000000000..c0f307117 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_checkbox.xml @@ -0,0 +1,16 @@ + + + + + diff --git a/src/android/app/src/main/res/layout/dialog_progress_bar.xml b/src/android/app/src/main/res/layout/dialog_progress_bar.xml new file mode 100644 index 000000000..a81157a29 --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_progress_bar.xml @@ -0,0 +1,26 @@ + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/dialog_seekbar.xml b/src/android/app/src/main/res/layout/dialog_seekbar.xml new file mode 100644 index 000000000..35abecfcb --- /dev/null +++ b/src/android/app/src/main/res/layout/dialog_seekbar.xml @@ -0,0 +1,37 @@ + + + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/layout/filepicker_toolbar.xml b/src/android/app/src/main/res/layout/filepicker_toolbar.xml new file mode 100644 index 000000000..644934171 --- /dev/null +++ b/src/android/app/src/main/res/layout/filepicker_toolbar.xml @@ -0,0 +1,32 @@ + + + + + + + + + + diff --git a/src/android/app/src/main/res/layout/fragment_cheat_details.xml b/src/android/app/src/main/res/layout/fragment_cheat_details.xml new file mode 100644 index 000000000..25b1a268a --- /dev/null +++ b/src/android/app/src/main/res/layout/fragment_cheat_details.xml @@ -0,0 +1,163 @@ + + + + + + + + + + + + + + + + + + + + + + + + + +