From 9df12f950b088040efb1b33f207dac15eaa850ba Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Thu, 13 Jun 2024 20:25:12 +0200 Subject: [PATCH 01/24] android: target android 13 disable non-android CI jobs --- .github/workflows/bsd.yml | 48 ------ .github/workflows/c-cpp.yml | 182 ---------------------- .github/workflows/crowdin_prep.yml | 36 ----- .github/workflows/crowdin_translate.yml | 49 ------ .github/workflows/switch.yml | 62 -------- .github/workflows/uwp.yml | 54 ------- shell/android-studio/flycast/build.gradle | 4 +- 7 files changed, 2 insertions(+), 433 deletions(-) delete mode 100644 .github/workflows/bsd.yml delete mode 100644 .github/workflows/c-cpp.yml delete mode 100644 .github/workflows/crowdin_prep.yml delete mode 100644 .github/workflows/crowdin_translate.yml delete mode 100644 .github/workflows/switch.yml delete mode 100644 .github/workflows/uwp.yml diff --git a/.github/workflows/bsd.yml b/.github/workflows/bsd.yml deleted file mode 100644 index cd63b20c0..000000000 --- a/.github/workflows/bsd.yml +++ /dev/null @@ -1,48 +0,0 @@ -name: BSD CI - -on: [push, pull_request] - -jobs: - build: - env: - CCACHE_DIR: ${{ github.workspace }}/.ccache - runs-on: ubuntu-latest - strategy: - matrix: - operating_system: [ freebsd, netbsd, openbsd ] - architecture: [ arm64, x86-64 ] - include: - - operating_system: freebsd - version: '14.0' - pkginstall: sudo pkg install -y alsa-lib ccache cmake evdev-proto git libao libevdev libudev-devd libzip miniupnpc ninja pkgconf pulseaudio sdl2 - - operating_system: netbsd - version: '10.0' - pkginstall: sudo pkgin update && sudo pkgin -y install alsa-lib ccache cmake gcc12 git libao libzip miniupnpc ninja-build pkgconf pulseaudio SDL2 && export PATH=/usr/pkg/gcc12/bin:$PATH - - operating_system: openbsd - version: '7.5' - pkginstall: sudo pkg_add ccache cmake git libao libzip miniupnpc ninja pkgconf pulseaudio sdl2 - exclude: - - architecture: arm64 - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - - - uses: actions/cache@v4 - with: - path: ${{ env.CCACHE_DIR }} - key: ccache-${{ matrix.operating_system }}-${{ matrix.architecture }}-${{ github.sha }} - restore-keys: ccache-${{ matrix.operating_system }}-${{ matrix.architecture }}- - - - uses: cross-platform-actions/action@v0.24.0 - with: - operating_system: ${{ matrix.operating_system }} - architecture: ${{ matrix.architecture }} - version: ${{ matrix.version }} - environment_variables: CCACHE_DIR - run: | - ${{ matrix.pkginstall }} - cmake -B build -DCMAKE_BUILD_TYPE=Release -G Ninja - cmake --build build --config Release diff --git a/.github/workflows/c-cpp.yml b/.github/workflows/c-cpp.yml deleted file mode 100644 index 78120d27b..000000000 --- a/.github/workflows/c-cpp.yml +++ /dev/null @@ -1,182 +0,0 @@ -name: C/C++ CI - -on: [push, pull_request] - -jobs: - build: - name: ${{ matrix.config.name }} - runs-on: ${{ matrix.config.os }} - - defaults: - run: - shell: ${{ matrix.config.shell }} - - env: - CCACHE_DIR: ${{ github.workspace }}/.ccache - - strategy: - matrix: - config: - - {name: i686-pc-windows-msvc, os: windows-latest, shell: cmd, arch: x86, cmakeArgs: -G Ninja, buildType: Release} - - {name: apple-darwin, os: macos-latest, shell: sh, cmakeArgs: -G Xcode -DAPPLE_BREAKPAD=ON -DUSE_DISCORD=ON, destDir: osx, buildType: RelWithDebInfo} - - {name: apple-ios, os: macos-latest, shell: sh, cmakeArgs: -DCMAKE_SYSTEM_NAME=iOS -G Xcode, destDir: ios, buildType: Release} - - {name: x86_64-pc-linux-gnu, os: ubuntu-20.04, shell: sh, cmakeArgs: -G Ninja -DUSE_DISCORD=ON, destDir: linux, buildType: RelWithDebInfo} - - {name: x86_64-pc-windows-msvc, os: windows-latest, shell: cmd, arch: x64, cmakeArgs: -G Ninja -DUSE_DISCORD=ON, buildType: Release} - - {name: x86_64-w64-mingw32, os: windows-latest, shell: 'msys2 {0}', cmakeArgs: -G Ninja -DUSE_DISCORD=ON, destDir: win, buildType: RelWithDebInfo} - - {name: libretro-x86_64-pc-linux-gnu, os: ubuntu-latest, shell: sh, cmakeArgs: -DLIBRETRO=ON -DCMAKE_POSITION_INDEPENDENT_CODE=TRUE -G Ninja, buildType: Release} - - {name: libretro-x86_64-w64-mingw32, os: windows-latest, shell: 'msys2 {0}', cmakeArgs: -DLIBRETRO=ON -G Ninja, buildType: Release} - - steps: - - name: Set up build environment (macOS) - run: | - sudo xcode-select -switch /Applications/Xcode_15.3.app - # Unlink and re-link to prevent errors when github mac runner images install python outside of brew. See https://github.com/actions/setup-python/issues/577 - brew list -1 | grep python | while read formula; do brew unlink $formula; brew link --overwrite $formula; done - brew update || : - brew install libao ldid ninja pulseaudio - VULKAN_VER=1.3.261.1 && aria2c https://sdk.lunarg.com/sdk/download/$VULKAN_VER/mac/vulkansdk-macos-$VULKAN_VER.dmg - hdiutil attach ./vulkansdk-macos-*.dmg -mountpoint /Volumes/VulkanSDK - sudo /Volumes/VulkanSDK/InstallVulkan.app/Contents/MacOS/InstallVulkan --root $HOME/VulkanSDK --accept-licenses --default-answer --confirm-command install - hdiutil detach /Volumes/VulkanSDK - echo "VULKAN_SDK=$HOME/VulkanSDK/macOS" >> $GITHUB_ENV - if: runner.os == 'macOS' - - - name: Set up build environment (Linux) - run: | - sudo add-apt-repository ppa:christianrauch/libdecoration - sudo apt-get update - sudo apt-get -y install ccache libao-dev libasound2-dev libevdev-dev libgl1-mesa-dev liblua5.3-dev libminiupnpc-dev libpulse-dev libsdl2-dev libudev-dev libzip-dev ninja-build libcurl4-openssl-dev - sudo apt-get -y install libwayland-dev libdecor-0-dev libaudio-dev libjack-dev libsndio-dev libsamplerate0-dev libx11-dev libxext-dev libxrandr-dev libxcursor-dev libxfixes-dev libxi-dev libxss-dev libxkbcommon-dev libdrm-dev libgbm-dev libgles2-mesa-dev libegl1-mesa-dev libdbus-1-dev libibus-1.0-dev libudev-dev fcitx-libs-dev - if: runner.os == 'Linux' - - - name: Set up build environment (Windows, MinGW) - uses: msys2/setup-msys2@v2 - with: - msystem: MINGW64 - install: git make mingw-w64-x86_64-ccache mingw-w64-x86_64-cmake mingw-w64-x86_64-lua mingw-w64-x86_64-ninja mingw-w64-x86_64-SDL2 mingw-w64-x86_64-toolchain - if: matrix.config.shell == 'msys2 {0}' - - - name: Set up build environment (Windows, Visual Studio) - run: | - choco install ccache directx-sdk ninja - echo DXSDK_DIR=C:\Program Files (x86)\Microsoft DirectX SDK (June 2010)>> %GITHUB_ENV% - if: matrix.config.shell == 'cmd' - - - uses: ilammy/msvc-dev-cmd@v1 - with: - arch: ${{ matrix.config.arch }} - if: matrix.config.shell == 'cmd' - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: recursive - - - name: Compile a universal OpenMP (macOS) - run: HOMEBREW_NO_INSTALLED_DEPENDENTS_CHECK=1 brew reinstall --build-from-source --formula ./shell/apple/libomp.rb - if: matrix.config.name == 'apple-darwin' - - - name: Compile OpenMP.xcframework (iOS) - run: ./shell/apple/emulator-ios/OpenMP/build_ios_openmp.sh --disableSimulator - if: matrix.config.name == 'apple-ios' - - - uses: actions/cache@v4 - with: - path: ${{ env.CCACHE_DIR }} - key: ccache-${{ matrix.config.name }}-${{ github.sha }} - restore-keys: ccache-${{ matrix.config.name }}- - if: runner.os != 'macOS' - - - name: CMake - run: | - cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.config.buildType }} -DCMAKE_INSTALL_PREFIX=artifact ${{ matrix.config.cmakeArgs }} -DSENTRY_UPLOAD_URL=${{ env.SENTRY_UPLOAD_URL }} - cmake --build build --config ${{ matrix.config.buildType }} --target install - env: - SENTRY_UPLOAD_URL: ${{ secrets.SENTRY_UPLOAD_URL }} - - - name: Unit Tests - run: | - cmake -B build -DCMAKE_BUILD_TYPE=${{ matrix.config.buildType }} -DCMAKE_INSTALL_PREFIX=artifact -DENABLE_CTEST=ON ${{ matrix.config.cmakeArgs }} - cmake --build build --config ${{ matrix.config.buildType }} - ./build/flycast - if: matrix.config.name == 'x86_64-pc-linux-gnu' - - - name: Dump symbols - run: | - mkdir -p symbols - core/deps/breakpad/bin/dump_syms artifact/bin/flycast.exe > symbols/flycast.sym 2>/dev/null - strip artifact/bin/flycast.exe - if: matrix.config.name == 'x86_64-w64-mingw32' - - - name: Build breakpad (linux) - working-directory: core/deps/breakpad - run: | - ./configure - make - sudo make install - if: matrix.config.name == 'x86_64-pc-linux-gnu' - - - name: Dump symbols and make AppImage (linux) - run: | - mkdir -p symbols - dump_syms artifact/bin/flycast > symbols/flycast.sym 2>/dev/null - strip artifact/bin/flycast - shell/linux/make-appimage.sh . artifact/bin appimage - mv flycast-x86_64.AppImage artifact/bin - rm artifact/bin/flycast - if: matrix.config.name == 'x86_64-pc-linux-gnu' - - - uses: actions/upload-artifact@v4 - with: - name: flycast-${{ matrix.config.name }} - path: artifact/bin - - - name: Package app (macos) - run: | - cd artifact/bin - zip -rm flycast.app.zip Flycast.app - if: matrix.config.name == 'apple-darwin' - - - name: Package app (windows) - run: | - powershell Compress-Archive artifact/bin/flycast.exe artifact/bin/flycast.zip - rm artifact/bin/flycast.exe - if: matrix.config.name == 'x86_64-w64-mingw32' - - - name: Configure AWS Credentials - id: aws-credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: AKIAJOZQS4H2PHQWYFCA - aws-secret-access-key: ${{ secrets.S3_SECRET_KEY }} - aws-region: us-east-2 - if: github.repository == 'flyinghead/flycast' && github.event_name == 'push' && matrix.config.destDir != '' - - - name: Upload to S3 - run: aws s3 sync artifact/bin s3://flycast-builds/${{ matrix.config.destDir }}/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --follow-symlinks - shell: bash - if: ${{ steps.aws-credentials.outputs.aws-account-id != '' }} - - - name: Setup Sentry CLI - uses: mathieu-bour/setup-sentry-cli@v2 - env: - SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} - with: - url: https://sentry.io - token: ${{ env.SENTRY_TOKEN }} - organization: flycast - project: minidump - version: 2.21.2 - if: ${{ env.SENTRY_TOKEN != '' }} - - - name: Upload symbols to Sentry (Windows, macOS, Linux) - run: | - VERSION=$(git describe --tags --always) - sentry-cli releases new "$VERSION" - sentry-cli releases set-commits "$VERSION" --auto - sentry-cli debug-files upload symbols - shell: bash - env: - SENTRY_TOKEN: ${{ secrets.SENTRY_TOKEN }} - if: ${{ env.SENTRY_TOKEN != '' && (matrix.config.name == 'x86_64-w64-mingw32' || matrix.config.name == 'apple-darwin' || matrix.config.name == 'x86_64-pc-linux-gnu') }} - \ No newline at end of file diff --git a/.github/workflows/crowdin_prep.yml b/.github/workflows/crowdin_prep.yml deleted file mode 100644 index eeb03b709..000000000 --- a/.github/workflows/crowdin_prep.yml +++ /dev/null @@ -1,36 +0,0 @@ -# Prepare source texts & upload them to Crowdin - -name: Crowdin Source Texts Upload - -# on change to the English texts -on: - push: - branches: - - master - paths: - - 'shell/libretro/libretro_core_options.h' - -jobs: - upload_source_file: - runs-on: ubuntu-latest - steps: - - name: Setup Java JDK - uses: actions/setup-java@v4 - with: - java-version: 18 - distribution: zulu - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Checkout - uses: actions/checkout@v4 - - - name: Upload Source - shell: bash - env: - CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }} - run: | - python3 intl/upload_workflow.py $CROWDIN_API_KEY "flyinghead-flycast" "shell/libretro/libretro_core_options.h" diff --git a/.github/workflows/crowdin_translate.yml b/.github/workflows/crowdin_translate.yml deleted file mode 100644 index e912cbf16..000000000 --- a/.github/workflows/crowdin_translate.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Download translations form Crowdin & Recreate libretro_core_options_intl.h - -name: Crowdin Translation Integration - -on: - schedule: - # please choose a random time & weekday to avoid all repos synching at the same time - - cron: '15 22 * * 5' # Fridays at 10:15 PM, UTC - -jobs: - create_intl_file: - runs-on: ubuntu-latest - steps: - - name: Setup Java JDK - uses: actions/setup-java@v4 - with: - java-version: 18 - distribution: zulu - - - name: Setup Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - - - name: Checkout - uses: actions/checkout@v4 - with: - persist-credentials: false # otherwise, the token used is the GITHUB_TOKEN, instead of your personal access token. - fetch-depth: 0 # otherwise, there would be errors pushing refs to the destination repository. - - - name: Create intl file - shell: bash - env: - CROWDIN_API_KEY: ${{ secrets.CROWDIN_API_KEY }} - run: | - python3 intl/download_workflow.py $CROWDIN_API_KEY "flyinghead-flycast" "shell/libretro/libretro_core_options_intl.h" - - - name: Commit files - run: | - git config --local user.email "github-actions@github.com" - git config --local user.name "github-actions[bot]" - git add intl/*_workflow.py "shell/libretro/libretro_core_options_intl.h" - git commit -m "Fetch translations & Recreate libretro_core_options_intl.h" - - - name: GitHub Push - uses: ad-m/github-push-action@v0.8.0 - with: - github_token: ${{ secrets.GITHUB_TOKEN }} - branch: ${{ github.ref }} diff --git a/.github/workflows/switch.yml b/.github/workflows/switch.yml deleted file mode 100644 index 4d6398e44..000000000 --- a/.github/workflows/switch.yml +++ /dev/null @@ -1,62 +0,0 @@ -name: Nintendo Switch CI - -on: [push, pull_request] - -jobs: - build: - name: ${{ matrix.config.name }} - runs-on: ubuntu-latest - container: devkitpro/devkita64:latest - - strategy: - matrix: - config: - - {name: standalone, destDir: switch} - - {name: libretro, cmakeArgs: -DLIBRETRO=ON} - - steps: - - name: Set up build environment - run: | - sudo apt-get update - sudo apt-get -y install awscli ccache - - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - - - name: Mark git repository as safe - run: git config --global --add safe.directory $PWD - - - uses: actions/cache@v4 - with: - path: ~/.ccache - key: ccache-switch-${{ matrix.config.name }}-${{ github.sha }} - restore-keys: ccache-switch-${{ matrix.config.name }}- - - - name: CMake - run: | - $DEVKITPRO/portlibs/switch/bin/aarch64-none-elf-cmake -B build -DCMAKE_BUILD_TYPE=Release -DCMAKE_INSTALL_PREFIX=artifact -G Ninja ${{ matrix.config.cmakeArgs }} - cmake --build build --config Release --target install - - - uses: actions/upload-artifact@v4 - with: - name: flycast-switch-${{ matrix.config.name }} - path: artifact/bin - - - name: Package app - run: zip -m artifact/bin/flycast.nro.zip artifact/bin/flycast.nro - if: hashFiles('artifact/bin/flycast.nro') != '' - - - name: Configure AWS Credentials - id: aws-credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: AKIAJOZQS4H2PHQWYFCA - aws-secret-access-key: ${{ secrets.S3_SECRET_KEY }} - aws-region: us-east-2 - if: github.repository == 'flyinghead/flycast' && github.event_name == 'push' && matrix.config.destDir != '' - - - name: Upload to S3 - run: aws s3 sync artifact/bin s3://flycast-builds/${{ matrix.config.destDir }}/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --follow-symlinks - if: ${{ steps.aws-credentials.outputs.aws-account-id != '' }} diff --git a/.github/workflows/uwp.yml b/.github/workflows/uwp.yml deleted file mode 100644 index 8c7450a1b..000000000 --- a/.github/workflows/uwp.yml +++ /dev/null @@ -1,54 +0,0 @@ -name: Windows UWP CI - -on: [push, pull_request] - -jobs: - build: - runs-on: windows-latest - - steps: - - uses: actions/checkout@v4 - with: - fetch-depth: 0 - submodules: true - - - name: CMake - run: | - cmake -B build -DCMAKE_BUILD_TYPE=Release -G "Visual Studio 17 2022" -A x64 -DCMAKE_SYSTEM_NAME=WindowsStore -DCMAKE_SYSTEM_VERSION=10.0.22000.0 - cmake --build build --config Release -- /m - shell: cmd - - - uses: ilammy/msvc-dev-cmd@v1 - - - name: Package app - run: | - mkdir build\artifact - cd build\AppPackages\flycast\flycast_*_x64_Test - mkdir tmp - ren *.msix flycast.msix - makeappx.exe unpack /p .\flycast.msix /d tmp - copy ..\..\..\Release\*.dll tmp - makeappx pack /d tmp /p ..\..\..\artifact\flycast.appx - - - name: Sign app - run: signtool sign /f shell\uwp\sign_cert.pfx /p '${{ secrets.SIGN_CERT_PWD }}' /v /fd SHA256 build\artifact\flycast.appx - if: github.repository == 'flyinghead/flycast' && github.event_name == 'push' - - - uses: actions/upload-artifact@v4 - with: - name: flycast-uwp - path: build/artifact - - - name: Configure AWS Credentials - id: aws-credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: AKIAJOZQS4H2PHQWYFCA - aws-secret-access-key: ${{ secrets.S3_SECRET_KEY }} - aws-region: us-east-2 - if: github.repository == 'flyinghead/flycast' && github.event_name == 'push' - - - name: Upload to S3 - run: aws s3 sync build/artifact s3://flycast-builds/xbox/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --follow-symlinks - shell: bash - if: ${{ steps.aws-credentials.outputs.aws-account-id != '' }} diff --git a/shell/android-studio/flycast/build.gradle b/shell/android-studio/flycast/build.gradle index dce29d0b0..5159b29fd 100644 --- a/shell/android-studio/flycast/build.gradle +++ b/shell/android-studio/flycast/build.gradle @@ -19,13 +19,13 @@ def getSentryUrl = { -> android { namespace 'com.flycast.emulator' ndkVersion '23.2.8568313' - compileSdk 29 + compileSdk 33 defaultConfig { applicationId "com.flycast.emulator" minSdk 16 //noinspection ExpiredTargetSdkVersion - targetSdk 29 + targetSdk 33 versionCode 8 versionName getVersionName() vectorDrawables.useSupportLibrary = true From 2103dd5a7a1186e50cbd5a894524da056c60711e Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 14 Jun 2024 12:39:32 +0200 Subject: [PATCH 02/24] storage: add exists() method. load bios from content path. android fix Add Storage::exists() method Allow dc and arcade bios to be loaded from content path. Fix android getSubPath when called on a root uri. --- core/hw/flashrom/flashrom.cpp | 3 +- core/oslib/oslib.cpp | 12 +++--- core/oslib/storage.cpp | 26 +++++++++++ core/oslib/storage.h | 2 + core/stdclass.cpp | 4 +- core/ui/gui_util.cpp | 7 +-- .../com/flycast/emulator/AndroidStorage.java | 43 +++++++++++++++---- .../src/main/jni/src/android_storage.h | 14 ++++++ 8 files changed, 88 insertions(+), 23 deletions(-) diff --git a/core/hw/flashrom/flashrom.cpp b/core/hw/flashrom/flashrom.cpp index f848af3de..3c501b44e 100644 --- a/core/hw/flashrom/flashrom.cpp +++ b/core/hw/flashrom/flashrom.cpp @@ -17,10 +17,11 @@ #include "flashrom.h" #include "oslib/oslib.h" #include "stdclass.h" +#include "oslib/storage.h" bool MemChip::Load(const std::string& file) { - FILE *f = nowide::fopen(file.c_str(), "rb"); + FILE *f = hostfs::storage().openFile(file, "rb"); if (f) { bool rv = std::fread(data + write_protect_size, 1, size - write_protect_size, f) == size - write_protect_size; diff --git a/core/oslib/oslib.cpp b/core/oslib/oslib.cpp index fd61df6f8..025f63b30 100644 --- a/core/oslib/oslib.cpp +++ b/core/oslib/oslib.cpp @@ -78,12 +78,12 @@ std::string findFlash(const std::string& prefix, const std::string& names) name = name.replace(percent, 1, prefix); std::string fullpath = get_readonly_data_path(name); - if (file_exists(fullpath)) + if (hostfs::storage().exists(fullpath)) return fullpath; for (const auto& path : config::ContentPath.get()) { - fullpath = path + "/" + name; - if (file_exists(fullpath)) + fullpath = hostfs::storage().getSubPath(path, name); + if (hostfs::storage().exists(fullpath)) return fullpath; } @@ -103,14 +103,14 @@ std::string getFlashSavePath(const std::string& prefix, const std::string& name) std::string findNaomiBios(const std::string& name) { std::string fullpath = get_readonly_data_path(name); - if (file_exists(fullpath)) + if (hostfs::storage().exists(fullpath)) return fullpath; for (const auto& path : config::ContentPath.get()) { try { fullpath = hostfs::storage().getSubPath(path, name); - hostfs::storage().getFileInfo(fullpath); - return fullpath; + if (hostfs::storage().exists(fullpath)) + return fullpath; } catch (const hostfs::StorageException& e) { } } diff --git a/core/oslib/storage.cpp b/core/oslib/storage.cpp index 6f497b8ba..0aaa4b090 100644 --- a/core/oslib/storage.cpp +++ b/core/oslib/storage.cpp @@ -40,6 +40,7 @@ CustomStorage& customStorage() std::string getParentPath(const std::string& path) override { die("Not implemented"); } std::string getSubPath(const std::string& reference, const std::string& relative) override { die("Not implemented"); } FileInfo getFileInfo(const std::string& path) override { die("Not implemented"); } + bool exists(const std::string& path) override { die("Not implemented"); } void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override { die("Not implemented"); } @@ -218,6 +219,23 @@ public: return info; } + bool exists(const std::string& path) override + { +#ifndef _WIN32 + struct stat st; + return flycast::stat(path.c_str(), &st) == 0; +#else // _WIN32 + nowide::wstackstring wname; + if (wname.convert(path.c_str())) + { + WIN32_FILE_ATTRIBUTE_DATA fileAttribs; + if (GetFileAttributesExW(wname.get(), GetFileExInfoStandard, &fileAttribs)) + return true; + } + return false; +#endif + } + private: std::vector listRoots() { @@ -319,6 +337,14 @@ FileInfo AllStorage::getFileInfo(const std::string& path) return stdStorage.getFileInfo(path); } +bool AllStorage::exists(const std::string& path) +{ + if (customStorage().isKnownPath(path)) + return customStorage().exists(path); + else + return stdStorage.exists(path); +} + std::string AllStorage::getDefaultDirectory() { std::string directory; diff --git a/core/oslib/storage.h b/core/oslib/storage.h index e1d1b9811..ae5a1531c 100644 --- a/core/oslib/storage.h +++ b/core/oslib/storage.h @@ -52,6 +52,7 @@ public: virtual std::string getParentPath(const std::string& path) = 0; virtual std::string getSubPath(const std::string& reference, const std::string& subpath) = 0; virtual FileInfo getFileInfo(const std::string& path) = 0; + virtual bool exists(const std::string& path) = 0; virtual ~Storage() = default; }; @@ -72,6 +73,7 @@ public: std::string getParentPath(const std::string& path) override; std::string getSubPath(const std::string& reference, const std::string& subpath) override; FileInfo getFileInfo(const std::string& path) override; + bool exists(const std::string& path) override; std::string getDefaultDirectory(); }; diff --git a/core/stdclass.cpp b/core/stdclass.cpp index 32b26bd3d..36e6c7b54 100644 --- a/core/stdclass.cpp +++ b/core/stdclass.cpp @@ -97,8 +97,8 @@ std::string get_readonly_data_path(const std::string& filename) std::string parent = hostfs::storage().getParentPath(settings.content.path); try { std::string filepath = hostfs::storage().getSubPath(parent, filename); - hostfs::FileInfo info = hostfs::storage().getFileInfo(filepath); - return info.path; + if (hostfs::storage().exists(filepath)) + return filepath; } catch (const FlycastException&) { } // Not found, so we return the user variant diff --git a/core/ui/gui_util.cpp b/core/ui/gui_util.cpp index 62ee49c74..8654a4d2c 100644 --- a/core/ui/gui_util.cpp +++ b/core/ui/gui_util.cpp @@ -809,12 +809,7 @@ std::future ImguiStateTexture::asyncLoad; bool ImguiStateTexture::exists() { std::string path = hostfs::getSavestatePath(config::SavestateSlot, false); - try { - hostfs::storage().getFileInfo(path); - return true; - } catch (...) { - return false; - } + return hostfs::storage().exists(path); } ImTextureID ImguiStateTexture::getId() diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java index df1fc3899..0954cb3ba 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java @@ -126,15 +126,17 @@ public class AndroidStorage { } public String getSubPath(String reference, String relative) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) + { Uri refUri = Uri.parse(reference); - String docId = DocumentsContract.getDocumentId(refUri); - String ret; - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) - ret = DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString(); - else - ret = DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString(); - return ret; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String docId = DocumentsContract.getTreeDocumentId(refUri); + return DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString(); + } + else { + String docId = DocumentsContract.getDocumentId(refUri); + return DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString(); + } } else { throw new UnsupportedOperationException("getSubPath unsupported"); @@ -143,6 +145,7 @@ public class AndroidStorage { public FileInfo getFileInfo(String uriString) throws FileNotFoundException { Uri uri = Uri.parse(uriString); + // FIXME >= Build.VERSION_CODES.LOLLIPOP DocumentFile docFile = DocumentFile.fromTreeUri(activity, uri); if (!docFile.exists()) throw new FileNotFoundException(uriString); @@ -156,6 +159,30 @@ public class AndroidStorage { return info; } + public boolean exists(String uriString) + { + Uri uri = Uri.parse(uriString); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (!DocumentsContract.isDocumentUri(activity, uri)) + { + String documentId = DocumentsContract.getTreeDocumentId(uri); + uri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + } + } + Cursor cursor = null; + try { + cursor = activity.getContentResolver().query(uri, new String[]{ DocumentsContract.Document.COLUMN_DISPLAY_NAME }, + null, null, null); + boolean ret = cursor != null && cursor.moveToNext(); + return ret; + } catch (Exception e) { + return false; + } finally { + if (cursor != null) + cursor.close(); + } + } + public void addStorage(boolean isDirectory, boolean writeAccess) { Intent intent = new Intent(isDirectory ? Intent.ACTION_OPEN_DOCUMENT_TREE : Intent.ACTION_OPEN_DOCUMENT); if (!isDirectory) { diff --git a/shell/android-studio/flycast/src/main/jni/src/android_storage.h b/shell/android-studio/flycast/src/main/jni/src/android_storage.h index af7ee59b8..b35fcb11a 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_storage.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_storage.h @@ -37,6 +37,7 @@ public: jgetParentUri = env->GetMethodID(clazz, "getParentUri", "(Ljava/lang/String;)Ljava/lang/String;"); jgetSubPath = env->GetMethodID(clazz, "getSubPath", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); jgetFileInfo = env->GetMethodID(clazz, "getFileInfo", "(Ljava/lang/String;)Lcom/flycast/emulator/FileInfo;"); + jexists = env->GetMethodID(clazz, "exists", "(Ljava/lang/String;)Z"); jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)V"); jsaveScreenshot = env->GetMethodID(clazz, "saveScreenshot", "(Ljava/lang/String;[B)V"); } @@ -119,6 +120,18 @@ public: return fromJavaFileInfo(jinfo); } + bool exists(const std::string& uri) override + { + jni::String juri(uri); + bool ret = jni::env()->CallBooleanMethod(jstorage, jexists, (jstring)juri); + try { + checkException(); + return ret; + } catch (...) { + return false; + } + } + void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override { jni::env()->CallVoidMethod(jstorage, jaddStorage, isDirectory, writeAccess); @@ -194,6 +207,7 @@ private: jmethodID jaddStorage; jmethodID jgetSubPath; jmethodID jgetFileInfo; + jmethodID jexists; jmethodID jsaveScreenshot; // FileInfo accessors lazily initialized to avoid having to load the class jmethodID jgetName = nullptr; From 977d6c6f95c8b673db7ed7ab68463bf4842413f9 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 14 Jun 2024 12:59:19 +0200 Subject: [PATCH 03/24] android: upload to google play --- .github/workflows/android.yml | 24 +++++++--------------- shell/android-studio/flycast/build.gradle | 8 +++++++- shell/android-studio/playstore.jks | Bin 0 -> 2622 bytes 3 files changed, 14 insertions(+), 18 deletions(-) create mode 100644 shell/android-studio/playstore.jks diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 09391757a..5939387ac 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -31,14 +31,17 @@ jobs: - name: Gradle working-directory: shell/android-studio - run: ./gradlew assembleRelease --parallel + run: ./gradlew bundleRelease --parallel env: SENTRY_UPLOAD_URL: ${{ secrets.SENTRY_UPLOAD_URL }} - - uses: actions/upload-artifact@v4 + - uses: r0adkll/upload-google-play@v1.1.3 with: - name: flycast-release.apk - path: shell/android-studio/flycast/build/outputs/apk/release/flycast-release.apk + serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} + packageName: com.flycast.emulator + releaseFiles: shell/android-studio/flycast/build/outputs/bundle/release/flycast-release.aab + track: qa + status: draft - name: Build dump_syms working-directory: core/deps/breakpad @@ -58,19 +61,6 @@ jobs: mv libflycast.so.sym symbols/libflycast.so/$BUILD_ID done - - name: Configure AWS Credentials - id: aws-credentials - uses: aws-actions/configure-aws-credentials@v4 - with: - aws-access-key-id: AKIAJOZQS4H2PHQWYFCA - aws-secret-access-key: ${{ secrets.S3_SECRET_KEY }} - aws-region: us-east-2 - if: github.repository == 'flyinghead/flycast' && github.event_name == 'push' - - - name: Upload to S3 - run: aws s3 sync shell/android-studio/flycast/build/outputs/apk/release s3://flycast-builds/android/${GITHUB_REF#refs/}-$GITHUB_SHA --acl public-read --exclude='*.json' --follow-symlinks - if: ${{ steps.aws-credentials.outputs.aws-account-id != '' }} - - name: Setup Sentry CLI uses: mathieu-bour/setup-sentry-cli@v2 env: diff --git a/shell/android-studio/flycast/build.gradle b/shell/android-studio/flycast/build.gradle index 5159b29fd..c209f1794 100644 --- a/shell/android-studio/flycast/build.gradle +++ b/shell/android-studio/flycast/build.gradle @@ -49,13 +49,19 @@ android { debug { storeFile file("../debug.keystore") } + release { + storeFile file("../playstore.jks") + storePassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + keyAlias 'uploadkey' + keyPassword System.getenv("ANDROID_KEYSTORE_PASSWORD") + } } buildTypes { release { minifyEnabled false proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' - signingConfig signingConfigs.debug + signingConfig signingConfigs.release } } compileOptions { diff --git a/shell/android-studio/playstore.jks b/shell/android-studio/playstore.jks new file mode 100644 index 0000000000000000000000000000000000000000..ec5e2a7fc740635fb85e0ca62b914c0dac3acdd5 GIT binary patch literal 2622 zcma);XEYoN7RRSz^fDt}^e%{u-usvkB}#4`UiQa;d=y@3)k>C-1M6`#j zp6ET%MwAgr*zDOo+1*dOAMUy5{{Q#>KmN`|;%QYtKrj*y+n|DpC+a6s7=YBkr+C;j z1P^=nL;eeihraqR3tA1qL(6~2gdg>oitb-m^t3?GQ#@4bhp2{h|9JsrKw^+e{~qa) z5)e2`kUm;@t9Djqrda(;+n@H0T&-RpkSH?<$cLn-qWNbc2nGcpxv5}IiTXe{Fa#(L zVQ!-VME>?}obu+Q!qtPmHyaQ?G=Xm_yGrwk7qOwuF<`>drL$M3h-&&R|cotn;ntn37huFZoM3{^6n0#|&UFQ?rdM zck_yb*ZrD-d+L|{yY4I^-{j$p0R4wGQI#C-+Xg(@A zU}EX8UFaCP`7I+)zCice;lAGu&x@8%KvLsO1tJG~Mwa+&Dd@FX?PX|Jbg+{`-$A6J zq!dyguw$p?Py?tM)ujAKKc6_(zK~2cjSC*98OJeX&9&QI{IxCmr$f-k}i61))o&tp{5(I&8jretk5}K9Mc$b|H35uXU8O z%7kv?)^4LmU7*4$zHUB!kuhRgM{bDuTrk*ApF`GS@wo=uaMFjIy(1Uj%SRB00};)U ztWxz`{_ebn^J4c2lxNpayuEb&B%R>CugvZk&U;M9_I%sDmFA4bwomDoS1oKi(ASQ` zHM&Qk49Z%K7X>FR#On1&Kp)Klr-`AY{OvF+#|kf&aTLUK+pP0|Rv5Z*mccZkGvIq{ zg+gTC_`~w8AbVx_SElmetWX7v+0Ed#^FuEGXBhiZ=Nu-F&NeB+xkGnW$ScvZ7rnEtqQy*VHjOj#Dt&syRvZ_^i^I8VG*g@Unz<$%Qaij0&*xfwh_KIT*`NJ1&kY zs(1V5(J3xmZAosl$-4C4wxA8DrRI5DL`nwxR9$TG&&zDF!TmyOrQ`$>^1LB-8VPy7 z1O!M)qmleSaS4M*bx$T)KChGAwXXPPkHVY( z2iBMCZ!1lNVjoyOP5r%kS>b&Q9vp+Qfslt)Y;ByH#B+|UEYD-hrceFfZF~?O+_gomy?Yh3r`LmS5`L8okKBa+h>v3C&!&<-S(AU=EgU0dPBX- z$$AV7O0-@y=!Mdp+0XSgUv_1gvZFkdqYX6j>g54O1`k5!2fuhnhHe^Y-l4yzFY+yG$aI=^Ph)30XojO7*Bu}7D8&j4a?v!Ika0JwZJh@09WQh|GsH36<>E z`V>@xq0d%ccqhI|AeY{^f$il!v5SPOh{!R-ZLR4CB~(spmNNQy*yiW)cwU0J{132; zS5~K}Zc#FYE9)L6AG zpOD`A?yl;?a^aFuqJ7BdLu&D|;|^DMR`ldCVI+#kC~8TPo7lDQrgKNTVL=o#A(1|T z8{t=TLBw}~s;Iknl`pBgHm3@YM#KNw8N`;6!lMaDW27{a;pa~U0zv^0J`92c{}7<0 zpDRwkTH4?B2sP-PVCy%bG|d=ZC&yDE4}tp|#(5c#U(D-m)oB1Mb6&gf21@?}0e=UY Cyq~!M literal 0 HcmV?d00001 From e8325091755e79b10b27c067f8b19e2b90078355 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 14 Jun 2024 13:16:15 +0200 Subject: [PATCH 04/24] android ci: missing keystore password --- .github/workflows/android.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 5939387ac..537e21f11 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -34,6 +34,7 @@ jobs: run: ./gradlew bundleRelease --parallel env: SENTRY_UPLOAD_URL: ${{ secrets.SENTRY_UPLOAD_URL }} + ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - uses: r0adkll/upload-google-play@v1.1.3 with: From e5ccf98693fa5e6abf91aeafe94e31544400ef25 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 14 Jun 2024 14:31:28 +0200 Subject: [PATCH 05/24] android ci: set track to internal --- .github/workflows/android.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 537e21f11..6ba5c03ba 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -41,7 +41,7 @@ jobs: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.flycast.emulator releaseFiles: shell/android-studio/flycast/build/outputs/bundle/release/flycast-release.aab - track: qa + track: internal status: draft - name: Build dump_syms From e4ca99afbae571280a722a02d29b36e187c8d6f8 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 14 Jun 2024 15:36:12 +0200 Subject: [PATCH 06/24] android ci: bump versionCode on each run --- .github/workflows/android.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 6ba5c03ba..85e9702a4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -29,6 +29,12 @@ jobs: key: android-ccache-${{ github.sha }} restore-keys: android-ccache- + - name: Bump version code + uses: chkfung/android-version-actions@v1.2.2 + with: + gradlePath: shell/android-studio/flycast/build.gradle + versionCode: ${{ github.run_number }} + - name: Gradle working-directory: shell/android-studio run: ./gradlew bundleRelease --parallel @@ -36,7 +42,8 @@ jobs: SENTRY_UPLOAD_URL: ${{ secrets.SENTRY_UPLOAD_URL }} ANDROID_KEYSTORE_PASSWORD: ${{ secrets.ANDROID_KEYSTORE_PASSWORD }} - - uses: r0adkll/upload-google-play@v1.1.3 + - name: Upload to Google Play + uses: r0adkll/upload-google-play@v1.1.3 with: serviceAccountJsonPlainText: ${{ secrets.PLAY_SERVICE_ACCOUNT_JSON }} packageName: com.flycast.emulator From 0a9eaa6cb863fa9f0af35b6d73b66055afc5c8ce Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Mon, 17 Jun 2024 09:53:32 +0200 Subject: [PATCH 07/24] android: storage fixes storage: add file update date to FileInfo. Savestates and custom textures can be loaded from content path / ASS. Cache last savestate update date to improve perf on android. android: Don't use scoped storage for android < 5. Optimize listContent. Fix getSubPath android: instrumented storage test --- core/nullDC.cpp | 45 ++++-- core/oslib/storage.cpp | 9 +- core/oslib/storage.h | 12 +- core/rend/CustomTexture.cpp | 18 ++- core/ui/gui.cpp | 22 +-- shell/android-studio/flycast/build.gradle | 6 + .../flycast/emulator/AndroidStorageTest.java | 145 ++++++++++++++++++ .../com/flycast/emulator/AndroidStorage.java | 127 ++++++++++----- .../com/flycast/emulator/BaseGLActivity.java | 5 + .../java/com/flycast/emulator/FileInfo.java | 9 ++ .../src/main/jni/src/android_storage.h | 13 +- shell/android-studio/gradle.properties | 2 + 12 files changed, 328 insertions(+), 85 deletions(-) create mode 100644 shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java diff --git a/core/nullDC.cpp b/core/nullDC.cpp index 5129cd830..d18543a0c 100644 --- a/core/nullDC.cpp +++ b/core/nullDC.cpp @@ -8,6 +8,7 @@ #include "ui/gui.h" #include "oslib/oslib.h" #include "oslib/directory.h" +#include "oslib/storage.h" #include "debug/gdb_server.h" #include "archive/rzip.h" #include "ui/mainui.h" @@ -17,6 +18,9 @@ #include "serialize.h" #include +static std::string lastStateFile; +static time_t lastStateTime; + struct SavestateHeader { void init() @@ -121,6 +125,8 @@ void dc_savestate(int index, const u8 *pngData, u32 pngSize) if (settings.network.online) return; + lastStateFile.clear(); + Serializer ser; dc_serialize(ser); @@ -189,7 +195,7 @@ void dc_loadstate(int index) u32 total_size = 0; std::string filename = hostfs::getSavestatePath(index, false); - FILE *f = nowide::fopen(filename.c_str(), "rb"); + FILE *f = hostfs::storage().openFile(filename, "rb"); if (f == nullptr) { WARN_LOG(SAVESTATE, "Failed to load state - could not open %s for reading", filename.c_str()); @@ -278,28 +284,39 @@ void dc_loadstate(int index) time_t dc_getStateCreationDate(int index) { std::string filename = hostfs::getSavestatePath(index, false); - FILE *f = nowide::fopen(filename.c_str(), "rb"); - if (f == nullptr) - return 0; - SavestateHeader header; - if (std::fread(&header, sizeof(header), 1, f) != 1 || !header.isValid()) + if (filename != lastStateFile) { - std::fclose(f); - struct stat st; - if (flycast::stat(filename.c_str(), &st) == 0) - return st.st_mtime; + lastStateFile = filename; + FILE *f = hostfs::storage().openFile(filename, "rb"); + if (f == nullptr) + lastStateTime = 0; else - return 0; + { + SavestateHeader header; + if (std::fread(&header, sizeof(header), 1, f) != 1 || !header.isValid()) + { + std::fclose(f); + try { + hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(filename); + lastStateTime = fileInfo.updateTime; + } catch (...) { + lastStateTime = 0; + } + } + else { + std::fclose(f); + lastStateTime = (time_t)header.creationDate; + } + } } - std::fclose(f); - return (time_t)header.creationDate; + return lastStateTime; } void dc_getStateScreenshot(int index, std::vector& pngData) { pngData.clear(); std::string filename = hostfs::getSavestatePath(index, false); - FILE *f = nowide::fopen(filename.c_str(), "rb"); + FILE *f = hostfs::storage().openFile(filename, "rb"); if (f == nullptr) return; SavestateHeader header; diff --git a/core/oslib/storage.cpp b/core/oslib/storage.cpp index 0aaa4b090..70a0c580c 100644 --- a/core/oslib/storage.cpp +++ b/core/oslib/storage.cpp @@ -41,7 +41,7 @@ CustomStorage& customStorage() std::string getSubPath(const std::string& reference, const std::string& relative) override { die("Not implemented"); } FileInfo getFileInfo(const std::string& path) override { die("Not implemented"); } bool exists(const std::string& path) override { die("Not implemented"); } - void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override { + bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override { die("Not implemented"); } }; @@ -191,6 +191,7 @@ public: } info.isDirectory = S_ISDIR(st.st_mode); info.size = st.st_size; + info.updateTime = st.st_mtime; #else // _WIN32 nowide::wstackstring wname; if (wname.convert(path.c_str())) @@ -200,6 +201,8 @@ public: { info.isDirectory = (fileAttribs.dwFileAttributes & FILE_ATTRIBUTE_DIRECTORY) != 0; info.size = fileAttribs.nFileSizeLow + ((u64)fileAttribs.nFileSizeHigh << 32); + u64 t = ((u64)fileAttribs.ftLastWriteTime.dwHighDateTime << 32) | fileAttribs.ftLastWriteTime.dwLowDateTime; + info.updateTime = t / 10000000 - 11644473600LL; // 100-nano to secs minus (unix epoch - windows epoch) } else { @@ -389,9 +392,9 @@ AllStorage& storage() return storage; } -void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) +bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) { - customStorage().addStorage(isDirectory, writeAccess, callback); + return customStorage().addStorage(isDirectory, writeAccess, callback); } } diff --git a/core/oslib/storage.h b/core/oslib/storage.h index ae5a1531c..416832d90 100644 --- a/core/oslib/storage.h +++ b/core/oslib/storage.h @@ -27,14 +27,18 @@ namespace hostfs struct FileInfo { FileInfo() = default; - FileInfo(const std::string& name, const std::string& path, bool isDirectory, size_t size = 0, bool isWritable = false) - : name(name), path(path), isDirectory(isDirectory), size(size), isWritable(isWritable) {} + FileInfo(const std::string& name, const std::string& path, bool isDirectory, + size_t size = 0, bool isWritable = false, u64 updateTime = 0) + : name(name), path(path), isDirectory(isDirectory), size(size), + isWritable(isWritable), updateTime(updateTime) { + } std::string name; std::string path; bool isDirectory = false; size_t size = 0; bool isWritable = false; + u64 updateTime = 0; }; class StorageException : public FlycastException @@ -60,7 +64,7 @@ public: class CustomStorage : public Storage { public: - virtual void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) = 0; + virtual bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) = 0; }; class AllStorage : public Storage @@ -78,7 +82,7 @@ public: }; AllStorage& storage(); -void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)); +bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)); // iterate depth-first over the files contained in a folder hierarchy class DirectoryTree diff --git a/core/rend/CustomTexture.cpp b/core/rend/CustomTexture.cpp index fd082cdd0..93078201f 100644 --- a/core/rend/CustomTexture.cpp +++ b/core/rend/CustomTexture.cpp @@ -107,13 +107,15 @@ bool CustomTexture::Init() if (!textures_path.empty()) { - DIR *dir = flycast::opendir(textures_path.c_str()); - if (dir != nullptr) - { - NOTICE_LOG(RENDERER, "Found custom textures directory: %s", textures_path.c_str()); - custom_textures_available = true; - flycast::closedir(dir); - loader_thread.Start(); + try { + hostfs::FileInfo fileInfo = hostfs::storage().getFileInfo(textures_path); + if (fileInfo.isDirectory) + { + NOTICE_LOG(RENDERER, "Found custom textures directory: %s", textures_path.c_str()); + custom_textures_available = true; + loader_thread.Start(); + } + } catch (const FlycastException& e) { } } } @@ -142,7 +144,7 @@ u8* CustomTexture::LoadCustomTexture(u32 hash, int& width, int& height) if (it == texture_map.end()) return nullptr; - FILE *file = nowide::fopen(it->second.c_str(), "rb"); + FILE *file = hostfs::storage().openFile(it->second, "rb"); if (file == nullptr) return nullptr; int n; diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index 43ec7bcfe..a1843b602 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -1708,16 +1708,7 @@ static void gui_settings_general() if (ImGui::Button("X")) to_delete = i; } -#ifdef __ANDROID__ - ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3)); - if (ImGui::Button("Add")) - { - hostfs::addStorage(true, false, [](bool cancelled, std::string selection) { - if (!cancelled) - addContentPath(selection); - }); - } -#else + const char *title = "Select a Content Directory"; select_file_popup(title, [](bool cancelled, std::string selection) { if (!cancelled) @@ -1725,6 +1716,17 @@ static void gui_settings_general() return true; }); ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3)); +#ifdef __ANDROID__ + if (ImGui::Button("Add")) + { + bool supported = hostfs::addStorage(true, false, [](bool cancelled, std::string selection) { + if (!cancelled) + addContentPath(selection); + }); + if (!supported) + ImGui::OpenPopup(title); + } +#else if (ImGui::Button("Add")) ImGui::OpenPopup(title); #endif diff --git a/shell/android-studio/flycast/build.gradle b/shell/android-studio/flycast/build.gradle index c209f1794..545c5731d 100644 --- a/shell/android-studio/flycast/build.gradle +++ b/shell/android-studio/flycast/build.gradle @@ -43,6 +43,7 @@ android { // avoid Error: Google Play requires that apps target API level 31 or higher. abortOnError false } + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" } signingConfigs { @@ -92,4 +93,9 @@ dependencies { implementation libs.slf4j.android implementation fileTree(dir: 'libs', include: ['*.aar', '*.jar'], exclude: []) implementation libs.documentfile + + androidTestImplementation 'androidx.test:runner:1.5.0' + androidTestImplementation 'androidx.test:rules:1.5.0' + androidTestImplementation 'androidx.test:core:1.5.0' + androidTestImplementation 'junit:junit:4.12' } diff --git a/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java b/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java new file mode 100644 index 000000000..9a9519c67 --- /dev/null +++ b/shell/android-studio/flycast/src/androidTest/java/com/flycast/emulator/AndroidStorageTest.java @@ -0,0 +1,145 @@ +package com.flycast.emulator; + +import android.util.Log; + +import androidx.test.core.app.ActivityScenario; +import androidx.test.runner.AndroidJUnit4; + +import org.junit.Test; +import org.junit.runner.RunWith; + +import java.util.Arrays; + +import static org.junit.Assert.*; + +@RunWith(AndroidJUnit4.class) +public class AndroidStorageTest { + public static final String TREE_URI = "content://com.android.externalstorage.documents/tree/primary%3AFlycast%2FROMS"; + + @Test + public void test() { + ActivityScenario scenario = ActivityScenario.launch(NativeGLActivity.class); + scenario.onActivity(activity -> { + try { + // Configure storage + AndroidStorage storage = activity.getStorage(); + String rootUri = TREE_URI; + storage.setStorageDirectories(Arrays.asList(rootUri)); + + // Start test + // exists (root) + assertTrue(storage.exists(rootUri)); + // listContent (root) + FileInfo[] kids = storage.listContent(rootUri); + assertTrue(kids.length > 0); + // getFileInfo (root) + FileInfo info = storage.getFileInfo(rootUri); + assertEquals(info.getPath(), rootUri); + assertTrue(info.isDirectory()); + assertNotEquals(0, info.getUpdateTime()); + // getParentUri (root) + // fails on lollipop_mr1, could be because parent folder (/Flycast) is also allowed + assertEquals("", storage.getParentUri(rootUri)); + + boolean directoryDone = false; + boolean fileDone = false; + for (FileInfo file : kids) { + if (file.isDirectory() && !directoryDone) { + // getParentUri + String parentUri = storage.getParentUri(file.getPath()); + // FIXME fails because getParentUri returns a docId, not a treeId + //assertEquals(rootUri, parentUri); + + // getSubPath (from root) + String kidUri = storage.getSubPath(rootUri, file.getName()); + assertEquals(file.getPath(), kidUri); + + // exists (folder) + assertTrue(storage.exists(file.getPath())); + + // getFileInfo (folder) + info = storage.getFileInfo(file.getPath()); + assertEquals(file.getPath(), info.getPath()); + assertEquals(file.getName(), info.getName()); + assertTrue(info.isDirectory()); + assertNotEquals(0, info.getUpdateTime()); + assertTrue(info.isDirectory()); + + // listContent (from folder) + FileInfo[] gdkids = storage.listContent(file.getPath()); + assertTrue(gdkids.length > 0); + for (FileInfo sfile : gdkids) { + if (!sfile.isDirectory()) { + // openFile + int fd = storage.openFile(sfile.getPath(), "r"); + assertNotEquals(-1, fd); + // getSubPath (from folder) + String uri = storage.getSubPath(file.getPath(), sfile.getName()); + assertEquals(sfile.getPath(), uri); + // getParentUri (from file) + uri = storage.getParentUri(sfile.getPath()); + assertEquals(file.getPath(), uri); + // exists (doc) + assertTrue(storage.exists(sfile.getPath())); + // getFileInfo (doc) + info = storage.getFileInfo(sfile.getPath()); + assertEquals(info.getPath(), sfile.getPath()); + assertEquals(info.getName(), sfile.getName()); + assertEquals(info.isDirectory(), sfile.isDirectory()); + assertNotEquals(0, info.getUpdateTime()); + assertFalse(info.isDirectory()); + } else { + // getParentUri (from subfolder) + String uri = storage.getParentUri(sfile.getPath()); + assertEquals(file.getPath(), uri); + // exists (subfolder) + assertTrue(storage.exists(sfile.getPath())); + } + } + directoryDone = true; + } + if (!file.isDirectory() && !fileDone) { + // getParentUri + String parentUri = storage.getParentUri(file.getPath()); + // FIXME fails because getParentUri returns a docId, not a treeId + //assertEquals(rootUri, parentUri); + // getSubPath (from root) + String kidUri = storage.getSubPath(rootUri, file.getName()); + assertEquals(file.getPath(), kidUri); + // exists (file) + assertTrue(storage.exists(file.getPath())); + fileDone = true; + } + } + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } + + //@Test + public void testLargeFolder() { + ActivityScenario scenario = ActivityScenario.launch(NativeGLActivity.class); + scenario.onActivity(activity -> { + try { + // Configure storage + AndroidStorage storage = activity.getStorage(); + String rootUri = TREE_URI; + storage.setStorageDirectories(Arrays.asList(rootUri)); + + // list content + String uri = storage.getSubPath(rootUri, "textures"); + uri = storage.getSubPath(uri, "T1401N"); + long t0 = System.currentTimeMillis(); + FileInfo[] kids = storage.listContent(uri); + Log.d("testLargeFolder", "Got " + kids.length + " in " + (System.currentTimeMillis() - t0) + " ms"); + // Got 2307 in 119910 ms !!! + // retrieving only uri in listContent: Got 2307 in 9007 ms + // retrieving uri+isDir: Got 2307 in 62281 ms + // manual listing: Got 2307 in 10212 ms + } catch (Exception e) { + throw new RuntimeException(e); + } + }); + } +} \ No newline at end of file diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java index 0954cb3ba..e3ac631fe 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java @@ -18,8 +18,8 @@ */ package com.flycast.emulator; -import android.annotation.SuppressLint; import android.app.Activity; +import android.content.ContentResolver; import android.content.ContentUris; import android.content.CursorLoader; import android.content.Intent; @@ -40,6 +40,7 @@ import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; import java.io.OutputStream; +import java.util.ArrayList; import java.util.List; public class AndroidStorage { @@ -62,7 +63,8 @@ public class AndroidStorage { public native void init(); public native void addStorageCallback(String path); - public void onAddStorageResult(Intent data) { + public void onAddStorageResult(Intent data) + { Uri uri = data == null ? null : data.getData(); if (uri == null) { // Cancelled @@ -71,14 +73,13 @@ public class AndroidStorage { else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) activity.getContentResolver().takePersistableUriPermission(uri, storageIntentPerms); - /* Use the uri path now to avoid issues when targeting sdk 30+ in the future - String realPath = getRealPath(uri); - // when targeting sdk 30+ (android 11+) using the real path doesn't work (empty content) -> *must* use the uri - int targetSdkVersion = activity.getApplication().getApplicationInfo().targetSdkVersion; - if (realPath != null && targetSdkVersion <= Build.VERSION_CODES.Q) - addStorageCallback(realPath); - else - */ + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { + String realPath = getRealPath(uri); + if (realPath != null) { + addStorageCallback(realPath); + return; + } + } addStorageCallback(uri.toString()); } } @@ -88,18 +89,53 @@ public class AndroidStorage { return pfd.detachFd(); } - public FileInfo[] listContent(String uri) { - DocumentFile docFile = DocumentFile.fromTreeUri(activity, Uri.parse(uri)); - DocumentFile kids[] = docFile.listFiles(); - FileInfo ret[] = new FileInfo[kids.length]; - for (int i = 0; i < kids.length; i++) { - ret[i] = new FileInfo(); - ret[i].setName(kids[i].getName()); - ret[i].setPath(kids[i].getUri().toString()); - ret[i].setDirectory(kids[i].isDirectory()); + public FileInfo[] listContent(String uri) + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + throw new UnsupportedOperationException("listContent unsupported"); + Uri treeUri = Uri.parse(uri); + String documentId; + if (DocumentsContract.isDocumentUri(activity, treeUri)) + documentId = DocumentsContract.getDocumentId(treeUri); + else + documentId = DocumentsContract.getTreeDocumentId(treeUri); + Uri docUri = DocumentsContract.buildDocumentUriUsingTree(treeUri, documentId); + final Uri childrenUri = DocumentsContract.buildChildDocumentsUriUsingTree(docUri, + DocumentsContract.getDocumentId(docUri)); + final ArrayList results = new ArrayList<>(); + + Cursor c = null; + try { + final ContentResolver resolver = activity.getContentResolver(); + c = resolver.query(childrenUri, new String[] { + DocumentsContract.Document.COLUMN_DOCUMENT_ID, + DocumentsContract.Document.COLUMN_DISPLAY_NAME, + DocumentsContract.Document.COLUMN_MIME_TYPE }, null, null, null); + while (c.moveToNext()) + { + final String childId = c.getString(0); + final Uri childUri = DocumentsContract.buildDocumentUriUsingTree(docUri, childId); + FileInfo info = new FileInfo(); + info.setPath(childUri.toString()); + info.setName(c.getString(1)); + info.setDirectory(DocumentsContract.Document.MIME_TYPE_DIR.equals(c.getString(2))); + results.add(info); + } + } catch (Exception e) { + Log.w("Flycast", "Failed query: " + e); + throw new RuntimeException(e); + } finally { + if (c != null) { + try { + c.close(); + } catch (RuntimeException rethrown) { + throw rethrown; + } catch (Exception ignored) { + } + } } - return ret; - } + return results.toArray(new FileInfo[results.size()]); + } public String getParentUri(String uriString) throws FileNotFoundException { if (uriString.isEmpty()) @@ -125,27 +161,27 @@ public class AndroidStorage { return uriString.substring(0, i); } - public String getSubPath(String reference, String relative) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) - { - Uri refUri = Uri.parse(reference); - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { - String docId = DocumentsContract.getTreeDocumentId(refUri); - return DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString(); - } - else { - String docId = DocumentsContract.getDocumentId(refUri); - return DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString(); - } - } - else { + public String getSubPath(String reference, String relative) + { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.KITKAT) throw new UnsupportedOperationException("getSubPath unsupported"); + Uri refUri = Uri.parse(reference); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + String docId; + if (DocumentsContract.isDocumentUri(activity, refUri)) + docId = DocumentsContract.getDocumentId(refUri); + else + docId = DocumentsContract.getTreeDocumentId(refUri); + return DocumentsContract.buildDocumentUriUsingTree(refUri, docId + "/" + relative).toString(); } + String docId = DocumentsContract.getDocumentId(refUri); + return DocumentsContract.buildDocumentUri(refUri.getAuthority(), docId + "/" + relative).toString(); } - public FileInfo getFileInfo(String uriString) throws FileNotFoundException { + public FileInfo getFileInfo(String uriString) throws FileNotFoundException + { Uri uri = Uri.parse(uriString); - // FIXME >= Build.VERSION_CODES.LOLLIPOP + // FIXME < Build.VERSION_CODES.LOLLIPOP DocumentFile docFile = DocumentFile.fromTreeUri(activity, uri); if (!docFile.exists()) throw new FileNotFoundException(uriString); @@ -155,6 +191,7 @@ public class AndroidStorage { info.setDirectory(docFile.isDirectory()); info.setSize(docFile.length()); info.setWritable(docFile.canWrite()); + info.setUpdateTime(docFile.lastModified() / 1000); return info; } @@ -183,7 +220,10 @@ public class AndroidStorage { } } - public void addStorage(boolean isDirectory, boolean writeAccess) { + public boolean addStorage(boolean isDirectory, boolean writeAccess) + { + if (isDirectory && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + return false; Intent intent = new Intent(isDirectory ? Intent.ACTION_OPEN_DOCUMENT_TREE : Intent.ACTION_OPEN_DOCUMENT); if (!isDirectory) { intent.addCategory(Intent.CATEGORY_OPENABLE); @@ -196,6 +236,8 @@ public class AndroidStorage { storageIntentPerms = Intent.FLAG_GRANT_READ_URI_PERMISSION | (writeAccess ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0); intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | storageIntentPerms); activity.startActivityForResult(intent, ADD_STORAGE_ACTIVITY_REQUEST); + + return true; } private String getRealPath(final Uri uri) { @@ -207,14 +249,15 @@ public class AndroidStorage { // From https://github.com/HBiSoft/PickiT // Copyright (c) [2020] [HBiSoft] - @SuppressLint("NewApi") String getRealPathFromURI_API19(final Uri uri) { final boolean isKitKat = Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT; - if (isKitKat && (DocumentsContract.isDocumentUri(activity, uri) || DocumentsContract.isTreeUri(uri))) { - final boolean isTree = DocumentsContract.isTreeUri(uri); - if (isExternalStorageDocument(uri)) { + final boolean isTree = Build.VERSION.SDK_INT >= Build.VERSION_CODES.N && DocumentsContract.isTreeUri(uri); + if (isKitKat && (DocumentsContract.isDocumentUri(activity, uri) || isTree)) + { + if (isExternalStorageDocument(uri)) + { final String docId = isTree ? DocumentsContract.getTreeDocumentId(uri) : DocumentsContract.getDocumentId(uri); final String[] split = docId.split(":"); final String type = split[0]; diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java index 1d02e1dff..a4cd5ebe1 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java @@ -193,6 +193,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. JNIdc.setExternalStorageDirectories(pathList.toArray()); } + // Testing + public AndroidStorage getStorage() { + return storage; + } + @Override protected void onDestroy() { super.onDestroy(); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java index 5b036a33d..ba007f8aa 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/FileInfo.java @@ -59,9 +59,18 @@ public class FileInfo { this.size = size; } + public long getUpdateTime() { + return updateTime; + } + + public void setUpdateTime(long updateTime) { + this.updateTime = updateTime; + } + String name; String path; boolean isDirectory; boolean isWritable; long size; + long updateTime; } diff --git a/shell/android-studio/flycast/src/main/jni/src/android_storage.h b/shell/android-studio/flycast/src/main/jni/src/android_storage.h index b35fcb11a..3fcb217b4 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_storage.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_storage.h @@ -38,7 +38,7 @@ public: jgetSubPath = env->GetMethodID(clazz, "getSubPath", "(Ljava/lang/String;Ljava/lang/String;)Ljava/lang/String;"); jgetFileInfo = env->GetMethodID(clazz, "getFileInfo", "(Ljava/lang/String;)Lcom/flycast/emulator/FileInfo;"); jexists = env->GetMethodID(clazz, "exists", "(Ljava/lang/String;)Z"); - jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)V"); + jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)Z"); jsaveScreenshot = env->GetMethodID(clazz, "saveScreenshot", "(Ljava/lang/String;[B)V"); } @@ -132,11 +132,13 @@ public: } } - void addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override + bool addStorage(bool isDirectory, bool writeAccess, void (*callback)(bool cancelled, std::string selectedPath)) override { - jni::env()->CallVoidMethod(jstorage, jaddStorage, isDirectory, writeAccess); + bool ret = jni::env()->CallBooleanMethod(jstorage, jaddStorage, isDirectory, writeAccess); checkException(); - addStorageCallback = callback; + if (ret) + addStorageCallback = callback; + return ret; } void doStorageCallback(jstring path) @@ -182,6 +184,7 @@ private: info.isDirectory = env->CallBooleanMethod(jinfo, jisDirectory); info.isWritable = env->CallBooleanMethod(jinfo, jisWritable); info.size = env->CallLongMethod(jinfo, jgetSize); + info.updateTime = env->CallLongMethod(jinfo, jgetUpdateTime); return info; } @@ -198,6 +201,7 @@ private: jisDirectory = env->GetMethodID(infoClass, "isDirectory", "()Z"); jisWritable = env->GetMethodID(infoClass, "isWritable", "()Z"); jgetSize = env->GetMethodID(infoClass, "getSize", "()J"); + jgetUpdateTime = env->GetMethodID(infoClass, "getUpdateTime", "()J"); } jobject jstorage; @@ -215,6 +219,7 @@ private: jmethodID jisDirectory = nullptr; jmethodID jisWritable = nullptr; jmethodID jgetSize = nullptr; + jmethodID jgetUpdateTime = nullptr; void (*addStorageCallback)(bool cancelled, std::string selectedPath); }; diff --git a/shell/android-studio/gradle.properties b/shell/android-studio/gradle.properties index 534063aa9..aaace3531 100644 --- a/shell/android-studio/gradle.properties +++ b/shell/android-studio/gradle.properties @@ -19,3 +19,5 @@ android.useAndroidX=true # resources declared in the library itself and none from the library's dependencies, # thereby reducing the size of the R class for that library android.nonTransitiveRClass=true +# Don't uninstall the apk after running tests +android.injected.androidTest.leaveApksInstalledAfterRun=true From 9976fe10fb211dff93d0a2b43da1556750e2f62d Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 21 Jun 2024 18:32:25 +0200 Subject: [PATCH 08/24] android: gamepad rumble support (untested) Use single thread to handle rumble for virtual and physical gamepads. Use heavy click effect for vgamepad buttons when available (android 10) Handle rumble inclination param. --- .../emulator/emu/VirtualJoystickDelegate.java | 69 +------- .../emulator/periph/InputDeviceManager.java | 121 +++++++++++--- .../emulator/periph/VibratorThread.java | 152 ++++++++++++++++++ .../flycast/src/main/jni/src/Android.cpp | 3 +- .../src/main/jni/src/android_gamepad.h | 6 +- 5 files changed, 267 insertions(+), 84 deletions(-) create mode 100644 shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java index 982147306..51d37c29f 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java @@ -2,18 +2,15 @@ package com.flycast.emulator.emu; import android.content.Context; import android.content.res.Configuration; -import android.os.Build; import android.os.Handler; -import android.os.VibrationEffect; -import android.os.Vibrator; import android.view.InputDevice; import android.view.MotionEvent; import android.view.ScaleGestureDetector; import android.view.View; -import com.flycast.emulator.Emulator; import com.flycast.emulator.periph.InputDeviceManager; import com.flycast.emulator.periph.VJoy; +import com.flycast.emulator.periph.VibratorThread; public class VirtualJoystickDelegate { private VibratorThread vibratorThread; @@ -41,19 +38,15 @@ public class VirtualJoystickDelegate { this.view = view; this.context = view.getContext(); - vibratorThread = new VibratorThread(context); - vibratorThread.start(); + vibratorThread = VibratorThread.getInstance(); readCustomVjoyValues(); scaleGestureDetector = new ScaleGestureDetector(context, new OscOnScaleGestureListener()); } public void stop() { - vibratorThread.stopVibrator(); - try { - vibratorThread.join(); - } catch (InterruptedException e) { - } + vibratorThread.stopThread(); + vibratorThread = null; } public void readCustomVjoyValues() { @@ -231,7 +224,7 @@ public class VirtualJoystickDelegate { // Not for analog if (vjoy[j][5] == 0) if (!editVjoyMode) { - vibratorThread.vibrate(); + vibratorThread.click(); } vjoy[j][5] = 2; } @@ -406,56 +399,4 @@ public class VirtualJoystickDelegate { selectedVjoyElement = -1; } } - - private class VibratorThread extends Thread - { - private Vibrator vibrator; - private boolean vibrate = false; - private boolean stopping = false; - - VibratorThread(Context context) { - vibrator = (Vibrator)context.getSystemService(Context.VIBRATOR_SERVICE); - } - - @Override - public void run() { - while (!stopping) { - boolean doVibrate; - synchronized (this) { - doVibrate = false; - try { - this.wait(); - } catch (InterruptedException e) { - } - if (vibrate) { - doVibrate = true; - vibrate = false; - } - } - if (doVibrate) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(Emulator.vibrationDuration, VibrationEffect.DEFAULT_AMPLITUDE)); - } else { - vibrator.vibrate(Emulator.vibrationDuration); - } - } - } - } - - public void stopVibrator() { - synchronized (this) { - stopping = true; - notify(); - } - } - - public void vibrate() { - if (Emulator.vibrationDuration > 0) { - synchronized (this) { - vibrate = true; - notify(); - } - } - } - } } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java index 305597141..f9a2bd95f 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java @@ -10,7 +10,10 @@ import android.view.InputDevice; import com.flycast.emulator.Emulator; import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; + import org.apache.commons.lang3.ArrayUtils; public final class InputDeviceManager implements InputManager.InputDeviceListener { @@ -21,6 +24,13 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene private InputManager inputManager; private int maple_port = 0; + private static class VibrationParams { + float power; + float inclination; + long stopTime; + } + private Map vibParams = new HashMap<>(); + public InputDeviceManager() { init(); @@ -30,7 +40,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene { maple_port = 0; if (applicationContext.getPackageManager().hasSystemFeature("android.hardware.touchscreen")) - joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid", new int[0], new int[0]); + joystickAdded(VIRTUAL_GAMEPAD_ID, "Virtual Gamepad", 0, "virtual_gamepad_uid", + new int[0], new int[0], getVibrator(VIRTUAL_GAMEPAD_ID) != null); int[] ids = InputDevice.getDeviceIds(); for (int id : ids) onInputDeviceAdded(id); @@ -65,7 +76,8 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene fullAxes.add(range.getAxis()); } joystickAdded(i, device.getName(), port, device.getDescriptor(), - ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0]))); + ArrayUtils.toPrimitive(fullAxes.toArray(new Integer[0])), ArrayUtils.toPrimitive(halfAxes.toArray(new Integer[0])), + getVibrator(i) != null); } } @@ -80,34 +92,107 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene public void onInputDeviceChanged(int i) { } - // Called from native code - private boolean rumble(int i, float power, float inclination, int duration_ms) { - Vibrator vibrator; + private Vibrator getVibrator(int i) { if (i == VIRTUAL_GAMEPAD_ID) { - vibrator = (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE); + return (Vibrator)Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE); } else { InputDevice device = InputDevice.getDevice(i); if (device == null) - return false; - vibrator = device.getVibrator(); - if (!vibrator.hasVibrator()) - return false; + return null; + Vibrator vibrator = device.getVibrator(); + return vibrator.hasVibrator() ? vibrator : null; } + } - if (power == 0) { - vibrator.cancel(); - } else { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, VibrationEffect.DEFAULT_AMPLITUDE)); - } else { - vibrator.vibrate(duration_ms); + // Called from native code + private boolean rumble(int i, float power, float inclination, int duration_ms) + { + Vibrator vibrator = getVibrator(i); + if (vibrator == null) + return false; + + VibrationParams params; + synchronized (this) { + params = vibParams.get(i); + if (params == null) { + params = new VibrationParams(); + vibParams.put(i, params); } } + if (power == 0) { + if (params.power != 0) + vibrator.cancel(); + } else { + if (inclination > 0) { + params.inclination = inclination * power; + params.stopTime = System.currentTimeMillis() + duration_ms; + } + else { + params.inclination = 0; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, (int)(power * 255))); + else + vibrator.vibrate(duration_ms); + if (inclination > 0) + VibratorThread.getInstance().setVibrating(); + } + params.power = power; return true; } + public boolean updateRumble() + { + List ids; + synchronized (this) { + ids = new ArrayList(vibParams.keySet()); + } + boolean active = false; + for (int id : ids) { + if (updateRumble(id)) + active = true; + } + return active; + } + + private boolean updateRumble(int i) + { + Vibrator vibrator = getVibrator(i); + VibrationParams params; + synchronized (this) { + params = vibParams.get(i); + } + if (vibrator == null || params == null || params.power == 0 || params.inclination == 0) + return false; + long remTime = params.stopTime - System.currentTimeMillis(); + if (remTime <= 0) { + params.power = 0; + params.inclination = 0; + vibrator.cancel(); + return false; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + vibrator.vibrate(VibrationEffect.createOneShot(remTime, (int)(params.inclination * remTime * 255))); + else + vibrator.vibrate(remTime); + return true; + } + + public void stopRumble() + { + List ids; + synchronized (this) { + ids = new ArrayList(vibParams.keySet()); + } + for (int id : ids) { + Vibrator vibrator = getVibrator(id); + if (vibrator != null) + vibrator.cancel(); + } + } + public static InputDeviceManager getInstance() { return INSTANCE; } @@ -118,7 +203,7 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene public native boolean joystickAxisEvent(int id, int button, int value); public native void mouseEvent(int xpos, int ypos, int buttons); public native void mouseScrollEvent(int scrollValue); - private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes); + private native void joystickAdded(int id, String name, int maple_port, String uniqueId, int[] fullAxes, int[] halfAxes, boolean rumbleEnabled); private native void joystickRemoved(int id); public native boolean keyboardEvent(int key, boolean pressed); public native void keyboardText(int c); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java new file mode 100644 index 000000000..b7e7b1051 --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java @@ -0,0 +1,152 @@ +/* + Copyright 2024 flyinghead + + This file is part of Flycast. + + Flycast is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + Flycast is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +package com.flycast.emulator.periph; + +import android.content.Context; +import android.os.Build; +import android.os.VibrationEffect; +import android.os.Vibrator; +import android.view.InputDevice; + +import androidx.annotation.RequiresApi; + +import com.flycast.emulator.Emulator; + +public class VibratorThread extends Thread { + private boolean stopping = false; + private boolean click = false; + private long nextRumbleUpdate = 0; + @RequiresApi(Build.VERSION_CODES.O) + private VibrationEffect clickEffect = null; + int clickDuration = 0; + private static VibratorThread INSTANCE = null; + + public static VibratorThread getInstance() { + synchronized (VibratorThread.class) { + if (INSTANCE == null) + INSTANCE = new VibratorThread(); + } + return INSTANCE; + } + + private VibratorThread() { + start(); + } + + private Vibrator getVibrator(int i) + { + if (i == InputDeviceManager.VIRTUAL_GAMEPAD_ID) { + return (Vibrator) Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE); + } + else { + InputDevice device = InputDevice.getDevice(i); + if (device == null) + return null; + Vibrator vibrator = device.getVibrator(); + return vibrator.hasVibrator() ? vibrator : null; + } + } + + @Override + public void run() + { + while (!stopping) + { + boolean doClick = false; + synchronized (this) { + try { + if (nextRumbleUpdate != 0) { + long waitTime = nextRumbleUpdate - System.currentTimeMillis(); + if (waitTime > 0) + this.wait(waitTime); + } + else { + this.wait(); + } + } catch (InterruptedException e) { + } + if (click) { + doClick = true; + click = false; + } + } + if (doClick) + doClick(); + if (nextRumbleUpdate != 0 && nextRumbleUpdate - System.currentTimeMillis() < 5) { + if (!InputDeviceManager.getInstance().updateRumble()) + nextRumbleUpdate = 0; + else + nextRumbleUpdate = System.currentTimeMillis() + 16667; + } + } + InputDeviceManager.getInstance().stopRumble(); + } + + public void stopThread() { + synchronized (this) { + stopping = true; + notify(); + } + try { + join(); + } catch (InterruptedException e) { + } + INSTANCE = null; + } + + public void click() { + if (Emulator.vibrationDuration > 0) { + synchronized (this) { + click = true; + notify(); + } + } + } + + private void doClick() + { + Vibrator vibrator = getVibrator(InputDeviceManager.VIRTUAL_GAMEPAD_ID); + if (vibrator == null) + return; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + { + if (clickEffect == null || clickDuration != Emulator.vibrationDuration) + { + clickDuration = Emulator.vibrationDuration; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) + clickEffect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK); + else + clickEffect = VibrationEffect.createOneShot(clickDuration, VibrationEffect.DEFAULT_AMPLITUDE); + } + vibrator.vibrate(clickEffect); + } else { + vibrator.vibrate(Emulator.vibrationDuration); + } + } + + public void setVibrating() + { + // FIXME possible race condition + synchronized (this) { + if (nextRumbleUpdate == 0) + nextRumbleUpdate = System.currentTimeMillis() + 16667; + notify(); + } + } +} diff --git a/shell/android-studio/flycast/src/main/jni/src/Android.cpp b/shell/android-studio/flycast/src/main/jni/src/Android.cpp index b2fce4df1..b8a261e75 100644 --- a/shell/android-studio/flycast/src/main/jni/src/Android.cpp +++ b/shell/android-studio/flycast/src/main/jni/src/Android.cpp @@ -511,7 +511,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa } extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickAdded(JNIEnv *env, jobject obj, jint id, jstring name, - jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes) + jint maple_port, jstring junique_id, jintArray fullAxes, jintArray halfAxes, jboolean hasRumble) { std::string joyname = jni::String(name, false); std::string unique_id = jni::String(junique_id, false); @@ -520,6 +520,7 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa std::shared_ptr gamepad = std::make_shared(maple_port, id, joyname.c_str(), unique_id.c_str(), full, half); AndroidGamepadDevice::AddAndroidGamepad(gamepad); + gamepad->setRumbleEnabled(hasRumble); } extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceManager_joystickRemoved(JNIEnv *env, jobject obj, jint id) { diff --git a/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h b/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h index c95b62057..c637e3c44 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_gamepad.h @@ -106,7 +106,6 @@ public: if (id == VIRTUAL_GAMEPAD_ID) { input_mapper = std::make_shared(); - rumbleEnabled = true; // hasAnalogStick = true; // TODO has an analog stick but input mapping isn't persisted } else @@ -310,9 +309,14 @@ public: void rumble(float power, float inclination, u32 duration_ms) override { + power *= rumblePower / 100.f; jboolean has_vibrator = jni::env()->CallBooleanMethod(input_device_manager, input_device_manager_rumble, android_id, power, inclination, duration_ms); rumbleEnabled = has_vibrator; } + void setRumbleEnabled(bool rumbleEnabled) { + this->rumbleEnabled = rumbleEnabled; + } + bool is_virtual_gamepad() override { return android_id == VIRTUAL_GAMEPAD_ID; } bool hasHalfAxis(int axis) const { return std::find(halfAxes.begin(), halfAxes.end(), axis) != halfAxes.end(); } From 83a27b9125ef11f5597da637140afcd3829c429e Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 21 Jun 2024 18:37:34 +0200 Subject: [PATCH 09/24] android: import/export home folder Use "folder" instead of "directory" in UI. When game list is empty, show button to easily add a new content path. Improve folder paths display in settings with middle ellipsis. android: import/export home folder content --- core/oslib/oslib.h | 5 + core/ui/gui.cpp | 137 ++++++---- core/ui/gui_util.cpp | 35 ++- core/ui/gui_util.h | 2 + .../com/flycast/emulator/AndroidStorage.java | 83 ++++++- .../com/flycast/emulator/BaseGLActivity.java | 14 +- .../java/com/flycast/emulator/HomeMover.java | 235 ++++++++++++++++++ .../src/main/jni/src/android_storage.h | 33 +++ shell/libretro/libretro_core_options.h | 4 +- 9 files changed, 498 insertions(+), 50 deletions(-) create mode 100644 shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java diff --git a/core/oslib/oslib.h b/core/oslib/oslib.h index 985f6cb77..916b33e98 100644 --- a/core/oslib/oslib.h +++ b/core/oslib/oslib.h @@ -62,6 +62,11 @@ namespace hostfs std::string getShaderCachePath(const std::string& filename); void saveScreenshot(const std::string& name, const std::vector& data); + +#ifdef __ANDROID__ + void importHomeDirectory(); + void exportHomeDirectory(); +#endif } static inline void *allocAligned(size_t alignment, size_t size) diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index cb0fd6a91..21cbc2a0e 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -1556,7 +1556,7 @@ static void contentpath_warning_popup() if (show_contentpath_selection) { scanner.stop(); - const char *title = "Select a Content Directory"; + const char *title = "Select a Content Folder"; ImGui::OpenPopup(title); select_file_popup(title, [](bool cancelled, std::string selection) { @@ -1627,17 +1627,44 @@ static void gui_debug_tab() #endif } -static void addContentPath(const std::string& path) +static void addContentPathCallback(const std::string& path) { auto& contentPath = config::ContentPath.get(); if (std::count(contentPath.begin(), contentPath.end(), path) == 0) { scanner.stop(); contentPath.push_back(path); + if (gui_state == GuiState::Main) + // when adding content path from empty game list + SaveSettings(); scanner.refresh(); } } +static void addContentPath(bool start) +{ + const char *title = "Select a Content Folder"; + select_file_popup(title, [](bool cancelled, std::string selection) { + if (!cancelled) + addContentPathCallback(selection); + return true; + }); +#ifdef __ANDROID__ + if (start) + { + bool supported = hostfs::addStorage(true, false, [](bool cancelled, std::string selection) { + if (!cancelled) + addContentPathCallback(selection); + }); + if (!supported) + ImGui::OpenPopup(title); + } +#else + if (start) + ImGui::OpenPopup(title); +#endif +} + static float calcComboWidth(const char *biggestLabel) { return ImGui::CalcTextSize(biggestLabel).x + ImGui::GetStyle().FramePadding.x * 2.0f + ImGui::GetFrameHeight(); } @@ -1689,7 +1716,7 @@ static void gui_settings_general() ImVec2 size; size.x = 0.0f; size.y = (ImGui::GetTextLineHeightWithSpacing() + ImGui::GetStyle().FramePadding.y * 2.f) - * (config::ContentPath.get().size() + 1) ;//+ ImGui::GetStyle().FramePadding.y * 2.f; + * (config::ContentPath.get().size() + 1); if (BeginListBox("Content Location", size, ImGuiWindowFlags_NavFlattened)) { @@ -1698,35 +1725,21 @@ static void gui_settings_general() { ImguiID _(config::ContentPath.get()[i].c_str()); ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", config::ContentPath.get()[i].c_str()); - ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("X").x - ImGui::GetStyle().FramePadding.x); - if (ImGui::Button("X")) + float maxW = ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize(ICON_FA_TRASH_CAN).x - ImGui::GetStyle().FramePadding.x * 2 + - ImGui::GetStyle().ItemSpacing.x; + std::string s = middleEllipsis(config::ContentPath.get()[i], maxW); + ImGui::Text("%s", s.c_str()); + ImGui::SameLine(0, maxW - ImGui::CalcTextSize(s.c_str()).x + ImGui::GetStyle().ItemSpacing.x); + if (ImGui::Button(ICON_FA_TRASH_CAN)) to_delete = i; } - const char *title = "Select a Content Directory"; - select_file_popup(title, [](bool cancelled, std::string selection) { - if (!cancelled) - addContentPath(selection); - return true; - }); ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3)); -#ifdef __ANDROID__ - if (ImGui::Button("Add")) - { - bool supported = hostfs::addStorage(true, false, [](bool cancelled, std::string selection) { - if (!cancelled) - addContentPath(selection); - }); - if (!supported) - ImGui::OpenPopup(title); - } -#else - if (ImGui::Button("Add")) - ImGui::OpenPopup(title); -#endif + const bool addContent = ImGui::Button("Add"); + addContentPath(addContent); ImGui::SameLine(); - if (ImGui::Button("Rescan Content")) + + if (ImGui::Button("Rescan Content")) scanner.refresh(); scrollWhenDraggingOnVoid(); @@ -1739,31 +1752,40 @@ static void gui_settings_general() } } ImGui::SameLine(); - ShowHelpMarker("The directories where your games are stored"); + ShowHelpMarker("The folders where your games are stored"); size.y = ImGui::GetTextLineHeightWithSpacing() * 1.25f + ImGui::GetStyle().FramePadding.y * 2.0f; #if defined(__linux__) && !defined(__ANDROID__) - if (BeginListBox("Data Directory", size, ImGuiWindowFlags_NavFlattened)) + if (BeginListBox("Data Folder", size, ImGuiWindowFlags_NavFlattened)) { ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", get_writable_data_path("").c_str()); + float w = ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x; + std::string s = middleEllipsis(get_writable_data_path(""), w); + ImGui::Text("%s", s.c_str()); ImGui::EndListBox(); } ImGui::SameLine(); - ShowHelpMarker("The directory containing BIOS files, as well as saved VMUs and states"); + ShowHelpMarker("The folder containing BIOS files, as well as saved VMUs and states"); #else - if (BeginListBox("Home Directory", size, ImGuiWindowFlags_NavFlattened)) +#if defined(__ANDROID__) || defined(TARGET_MAC) + size.y += ImGui::GetTextLineHeightWithSpacing() * 1.25f; +#endif + if (BeginListBox("Home Folder", size, ImGuiWindowFlags_NavFlattened)) { ImGui::AlignTextToFramePadding(); - ImGui::Text("%s", get_writable_config_path("").c_str()); + float w = ImGui::GetContentRegionAvail().x - ImGui::GetStyle().FramePadding.x; + std::string s = middleEllipsis(get_writable_config_path(""), w); + ImGui::Text("%s", s.c_str()); + ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3)); #ifdef __ANDROID__ - ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("Change").x - ImGui::GetStyle().FramePadding.x); - if (ImGui::Button("Change")) - gui_setState(GuiState::Onboarding); + if (ImGui::Button("Import")) + hostfs::importHomeDirectory(); + ImGui::SameLine(); + if (ImGui::Button("Export")) + hostfs::exportHomeDirectory(); #endif #ifdef TARGET_MAC - ImGui::SameLine(ImGui::GetContentRegionAvail().x - ImGui::CalcTextSize("Reveal in Finder").x - ImGui::GetStyle().FramePadding.x); if (ImGui::Button("Reveal in Finder")) { char temp[512]; @@ -1774,9 +1796,15 @@ static void gui_settings_general() ImGui::EndListBox(); } ImGui::SameLine(); - ShowHelpMarker("The directory where Flycast saves configuration files and VMUs. BIOS files should be in a subfolder named \"data\""); + ShowHelpMarker("The folder where Flycast saves configuration files and VMUs. BIOS files should be in a subfolder named \"data\""); #endif // !linux -#endif // !TARGET_IPHONE +#else // TARGET_IPHONE + { + ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(24, 3)); + if (ImGui::Button("Rescan Content")) + scanner.refresh(); + } +#endif OptionCheckbox("Box Art Game List", config::BoxartDisplayMode, "Display game cover art in the game list."); @@ -2787,7 +2815,7 @@ static void gui_settings_advanced() { ImGui::InputText("Lua Filename", &config::LuaFileName.get(), ImGuiInputTextFlags_CharsNoBlank, nullptr, nullptr); ImGui::SameLine(); - ShowHelpMarker("Specify lua filename to use. Should be located in Flycast config directory. Defaults to flycast.lua when empty."); + ShowHelpMarker("Specify lua filename to use. Should be located in Flycast config folder. Defaults to flycast.lua when empty."); } #endif } @@ -3205,8 +3233,10 @@ static void gui_display_content() gui_start_game(""); counter++; } + bool gameListEmpty = false; { scanner.get_mutex().lock(); + gameListEmpty = scanner.get_game_list().empty(); for (const auto& game : scanner.get_game_list()) { if (gui_state == GuiState::SelectDisk) @@ -3275,7 +3305,28 @@ static void gui_display_content() } scanner.get_mutex().unlock(); } + bool addContent = false; +#if !defined(TARGET_IPHONE) + if (gameListEmpty && gui_state != GuiState::SelectDisk) + { + const char *label = "Your game list is empty"; + // center horizontally + const float w = largeFont->CalcTextSizeA(largeFont->FontSize, FLT_MAX, -1.f, label).x + ImGui::GetStyle().FramePadding.x * 2; + ImGui::SameLine((ImGui::GetContentRegionMax().x - w) / 2); + if (ImGui::BeginChild("empty", ImVec2(0, 0), ImGuiChildFlags_AutoResizeX | ImGuiChildFlags_AutoResizeY, ImGuiWindowFlags_NavFlattened)) + { + ImGui::PushFont(largeFont); + ImGui::NewLine(); + ImGui::Text("%s", label); + ImguiStyleVar _(ImGuiStyleVar_FramePadding, ScaledVec2(20, 8)); + addContent = ImGui::Button("Add Game Folder"); + ImGui::PopFont(); + } + ImGui::EndChild(); + } +#endif ImGui::PopStyleVar(); + addContentPath(addContent); } scrollWhenDraggingOnVoid(); windowDragScroll(); @@ -3300,7 +3351,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection) if (!make_directory(data_path)) { WARN_LOG(BOOT, "Cannot create 'data' directory: %s", data_path.c_str()); - gui_error("Invalid selection:\nFlycast cannot write to this directory."); + gui_error("Invalid selection:\nFlycast cannot write to this folder."); return false; } } @@ -3311,7 +3362,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection) if (file == nullptr) { WARN_LOG(BOOT, "Cannot write in the 'data' directory"); - gui_error("Invalid selection:\nFlycast cannot write to this directory."); + gui_error("Invalid selection:\nFlycast cannot write to this folder."); return false; } fclose(file); @@ -3339,7 +3390,7 @@ static bool systemdir_selected_callback(bool cancelled, std::string selection) static void gui_display_onboarding() { - const char *title = "Select Flycast Home Directory"; + const char *title = "Select Flycast Home Folder"; ImGui::OpenPopup(title); select_file_popup(title, &systemdir_selected_callback); } diff --git a/core/ui/gui_util.cpp b/core/ui/gui_util.cpp index 8654a4d2c..83562dd96 100644 --- a/core/ui/gui_util.cpp +++ b/core/ui/gui_util.cpp @@ -54,6 +54,7 @@ void select_file_popup(const char *prompt, StringCallback callback, { fullScreenWindow(true); ImguiStyleVar _(ImGuiStyleVar_WindowRounding, 0); + ImguiStyleVar _1(ImGuiStyleVar_FramePadding, ImVec2(4, 3)); // default if (ImGui::BeginPopup(prompt, ImGuiWindowFlags_NoMove | ImGuiWindowFlags_NoResize )) { @@ -123,7 +124,7 @@ void select_file_popup(const char *prompt, StringCallback callback, if (!select_current_directory.empty() && select_current_directory != "/") { - if (ImGui::Selectable(".. Up to Parent Directory")) + if (ImGui::Selectable(".. Up to Parent Folder")) { subfolders_read = false; select_current_directory = hostfs::storage().getParentPath(select_current_directory); @@ -161,7 +162,7 @@ void select_file_popup(const char *prompt, StringCallback callback, ImGui::EndChild(); if (!selectFile) { - if (ImGui::Button("Select Current Directory", ScaledVec2(0, 30))) + if (ImGui::Button("Select Current Folder", ScaledVec2(0, 30))) { if (callback(false, select_current_directory)) { @@ -1009,3 +1010,33 @@ bool Toast::draw() return true; } + +std::string middleEllipsis(const std::string& s, float width) +{ + float tw = ImGui::CalcTextSize(s.c_str()).x; + if (tw <= width) + return s; + std::string ellipsis; + char buf[5]; + ImTextCharToUtf8(buf, ImGui::GetFont()->EllipsisChar); + for (int i = 0; i < ImGui::GetFont()->EllipsisCharCount; i++) + ellipsis += buf; + + int l = s.length() / 2; + int d = l; + + while (true) + { + std::string ss = s.substr(0, l / 2) + ellipsis + s.substr(s.length() - l / 2 - (l & 1)); + tw = ImGui::CalcTextSize(ss.c_str()).x; + if (tw == width) + return ss; + d /= 2; + if (d == 0) + return ss; + if (tw > width) + l -= d; + else + l += d; + } +} diff --git a/core/ui/gui_util.h b/core/ui/gui_util.h index 8bb28f6ed..7fec8ebae 100644 --- a/core/ui/gui_util.h +++ b/core/ui/gui_util.h @@ -310,3 +310,5 @@ private: u64 endTime = 0; std::mutex mutex; }; + +std::string middleEllipsis(const std::string& s, float width); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java index e3ac631fe..9685b1e9f 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java @@ -39,12 +39,15 @@ import java.io.File; import java.io.FileNotFoundException; import java.io.FileOutputStream; import java.io.IOException; +import java.io.InputStream; import java.io.OutputStream; import java.util.ArrayList; import java.util.List; public class AndroidStorage { public static final int ADD_STORAGE_ACTIVITY_REQUEST = 15012010; + public static final int EXPORT_HOME_ACTIVITY_REQUEST = 15012011; + public static final int IMPORT_HOME_ACTIVITY_REQUEST = 15012012; private Activity activity; @@ -62,6 +65,7 @@ public class AndroidStorage { public native void init(); public native void addStorageCallback(String path); + public native void reloadConfig(); public void onAddStorageResult(Intent data) { @@ -89,6 +93,30 @@ public class AndroidStorage { return pfd.detachFd(); } + public InputStream openInputStream(String uri) throws FileNotFoundException { + return activity.getContentResolver().openInputStream(Uri.parse(uri)); + } + public OutputStream openOutputStream(String parent, String name) throws FileNotFoundException { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) + throw new UnsupportedOperationException("not supported"); + Uri uri = Uri.parse(parent); + String subpath = getSubPath(parent, name); + if (!exists(subpath)) { + String documentId; + if (DocumentsContract.isDocumentUri(activity, uri)) + documentId = DocumentsContract.getDocumentId(uri); + else + documentId = DocumentsContract.getTreeDocumentId(uri); + uri = DocumentsContract.buildDocumentUriUsingTree(uri, documentId); + uri = DocumentsContract.createDocument(activity.getContentResolver(), uri, + "application/octet-stream", name); + } + else { + uri = Uri.parse(subpath); + } + return activity.getContentResolver().openOutputStream(uri); + } + public FileInfo[] listContent(String uri) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) @@ -220,6 +248,22 @@ public class AndroidStorage { } } + public String mkdir(String parent, String name) throws FileNotFoundException + { + Uri parentUri = Uri.parse(parent); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + if (!DocumentsContract.isDocumentUri(activity, parentUri)) { + String documentId = DocumentsContract.getTreeDocumentId(parentUri); + parentUri = DocumentsContract.buildDocumentUriUsingTree(parentUri, documentId); + } + Uri newDirUri = DocumentsContract.createDocument(activity.getContentResolver(), parentUri, DocumentsContract.Document.MIME_TYPE_DIR, name); + return newDirUri.toString(); + } + File dir = new File(parent, name); + dir.mkdir(); + return dir.getAbsolutePath(); + } + public boolean addStorage(boolean isDirectory, boolean writeAccess) { if (isDirectory && Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) @@ -231,7 +275,7 @@ public class AndroidStorage { intent = Intent.createChooser(intent, "Select a cheat file"); } else { - intent = Intent.createChooser(intent, "Select a content directory"); + intent = Intent.createChooser(intent, "Select a content folder"); } storageIntentPerms = Intent.FLAG_GRANT_READ_URI_PERMISSION | (writeAccess ? Intent.FLAG_GRANT_WRITE_URI_PERMISSION : 0); intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION | storageIntentPerms); @@ -496,4 +540,41 @@ public class AndroidStorage { throw new RuntimeException(e.getMessage()); } } + + public void exportHomeDirectory() + { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent = Intent.createChooser(intent, "Select an export folder"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION); + activity.startActivityForResult(intent, EXPORT_HOME_ACTIVITY_REQUEST); + } + + public void onExportHomeResult(Intent data) + { + Uri uri = data == null ? null : data.getData(); + if (uri == null) + // Cancelled + return; + HomeMover mover = new HomeMover(activity, this); + mover.copyHome(activity.getExternalFilesDir(null).toURI().toString(), uri.toString(), "Exporting home folder"); + } + + public void importHomeDirectory() + { + Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE); + intent = Intent.createChooser(intent, "Select an import folder"); + intent.addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION); + activity.startActivityForResult(intent, IMPORT_HOME_ACTIVITY_REQUEST); + } + + public void onImportHomeResult(Intent data) + { + Uri uri = data == null ? null : data.getData(); + if (uri == null) + // Cancelled + return; + HomeMover mover = new HomeMover(activity, this); + mover.setReloadConfigOnCompletion(true); + mover.copyHome(uri.toString(), activity.getExternalFilesDir(null).toURI().toString(), "Importing home folder"); + } } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java index a4cd5ebe1..dcd92f866 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java @@ -557,8 +557,18 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. @Override protected void onActivityResult(int requestCode, int resultCode, Intent data) { super.onActivityResult(requestCode, resultCode, data); - if (requestCode == AndroidStorage.ADD_STORAGE_ACTIVITY_REQUEST) - storage.onAddStorageResult(data); + switch (requestCode) + { + case AndroidStorage.ADD_STORAGE_ACTIVITY_REQUEST: + storage.onAddStorageResult(data); + break; + case AndroidStorage.IMPORT_HOME_ACTIVITY_REQUEST: + storage.onImportHomeResult(data); + break; + case AndroidStorage.EXPORT_HOME_ACTIVITY_REQUEST: + storage.onExportHomeResult(data); + break; + } } private static native void register(BaseGLActivity activity); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java new file mode 100644 index 000000000..0bd030a86 --- /dev/null +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java @@ -0,0 +1,235 @@ +/* + Copyright 2024 flyinghead + + This file is part of Flycast. + + Flycast is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 2 of the License, or + (at your option) any later version. + + Flycast is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Flycast. If not, see . +*/ +package com.flycast.emulator; + +import android.app.Activity; +import android.app.ProgressDialog; +import android.content.DialogInterface; +import android.net.Uri; +import android.util.Log; + +import java.io.File; +import java.io.FileInputStream; +import java.io.FileNotFoundException; +import java.io.FileOutputStream; +import java.io.InputStream; +import java.io.OutputStream; +import java.util.ArrayList; +import java.util.List; + +public class HomeMover { + private Activity activity; + private AndroidStorage storage; + private StorageWrapper wrapper; + private boolean migrationThreadCancelled = false; + + private boolean reloadConfigOnCompletion = false; + + private class StorageWrapper + { + private File getFile(String path) { + Uri uri = Uri.parse(path); + if (uri.getScheme().equals("file")) + return new File(uri.getPath()); + else + return null; + } + public String getSubPath(String parent, String kid) + { + File f = getFile(parent); + if (f != null) + return new File(f, kid).toURI().toString(); + else + return storage.getSubPath(parent, kid); + } + + public FileInfo[] listContent(String folder) + { + File dir = getFile(folder); + if (dir != null) + { + File[] files = dir.listFiles(); + List ret = new ArrayList<>(files.length); + for (File f : files) { + FileInfo info = new FileInfo(); + info.setName(f.getName()); + info.setDirectory(f.isDirectory()); + info.setPath(f.toURI().toString()); + ret.add(info); + } + return ret.toArray(new FileInfo[ret.size()]); + } + else { + return storage.listContent(folder); + } + } + + public InputStream openInputStream(String path) throws FileNotFoundException { + File file = getFile(path); + if (file != null) + return new FileInputStream(file); + else + return storage.openInputStream(path); + } + + public OutputStream openOutputStream(String parent, String name) throws FileNotFoundException { + File file = getFile(parent); + if (file != null) + return new FileOutputStream(new File(file, name)); + else + return storage.openOutputStream(parent, name); + } + + public boolean exists(String path) { + File file = getFile(path); + if (file != null) + return file.exists(); + else + return storage.exists(path); + } + + public String mkdir(String parent, String name) throws FileNotFoundException + { + File dir = getFile(parent); + if (dir != null) + { + File subfolder = new File(dir, name); + subfolder.mkdir(); + return subfolder.toURI().toString(); + } + else { + return storage.mkdir(parent, name); + } + } + } + + public HomeMover(Activity activity, AndroidStorage storage) { + this.activity = activity; + this.storage = storage; + this.wrapper = new StorageWrapper(); + } + + public void copyHome(String source, String dest, String message) + { + migrationThreadCancelled = false; + ProgressDialog progress = new ProgressDialog(activity); + progress.setTitle("Copying"); + progress.setMessage(message); + progress.setProgressStyle(ProgressDialog.STYLE_HORIZONTAL); + progress.setMax(1); + progress.setOnCancelListener(dialogInterface -> migrationThreadCancelled = true); + progress.show(); + + Thread thread = new Thread(new Runnable() { + private void copyFile(String path, String name, String toDir) + { + //Log.d("flycast", "Copying " + path + " to " + toDir); + try { + InputStream in = wrapper.openInputStream(path); + OutputStream out = wrapper.openOutputStream(toDir, name); + byte[] buf = new byte[8192]; + while (true) { + int len = in.read(buf); + if (len == -1) + break; + out.write(buf, 0, len); + } + out.close(); + in.close(); + } catch (Exception e) { + Log.e("flycast", "Error copying " + path, e); + } + } + + private void copyDir(String from, String toParent, String name) + { + //Log.d("flycast", "Copying folder " + from + " to " + toParent + " / " + name); + if (!wrapper.exists(from)) + return; + try { + String to = wrapper.getSubPath(toParent, name); + if (!wrapper.exists(to)) + to = wrapper.mkdir(toParent, name); + + FileInfo[] files = wrapper.listContent(from); + incrementMaxProgress(files.length); + for (FileInfo file : files) + { + if (migrationThreadCancelled) + break; + if (!file.isDirectory()) + copyFile(file.path, file.name, to); + else + copyDir(file.path, to, file.getName()); + incrementProgress(1); + } + } catch (Exception e) { + Log.e("flycast", "Error copying folder " + from, e); + } + } + + private void migrate() + { + incrementMaxProgress(3); + String path = wrapper.getSubPath(source, "emu.cfg"); + copyFile(path, "emu.cfg", dest); + if (migrationThreadCancelled) + return; + incrementProgress(1); + + String srcMappings = wrapper.getSubPath(source, "mappings"); + copyDir(srcMappings, dest, "mappings"); + if (migrationThreadCancelled) + return; + incrementProgress(1); + + String srcData = wrapper.getSubPath(source, "data"); + copyDir(srcData, dest, "data"); + incrementProgress(1); + } + + private void incrementMaxProgress(int max) { + activity.runOnUiThread(() -> { + progress.setMax(progress.getMax() + max); + }); + } + private void incrementProgress(int i) { + activity.runOnUiThread(() -> { + progress.incrementProgressBy(i); + }); + } + + @Override + public void run() + { + migrate(); + activity.runOnUiThread(() -> { + progress.dismiss(); + if (reloadConfigOnCompletion) + storage.reloadConfig(); + }); + } + }); + thread.start(); + } + + public void setReloadConfigOnCompletion(boolean reloadConfigOnCompletion) { + this.reloadConfigOnCompletion = reloadConfigOnCompletion; + } +} diff --git a/shell/android-studio/flycast/src/main/jni/src/android_storage.h b/shell/android-studio/flycast/src/main/jni/src/android_storage.h index 3fcb217b4..d21e35ea0 100644 --- a/shell/android-studio/flycast/src/main/jni/src/android_storage.h +++ b/shell/android-studio/flycast/src/main/jni/src/android_storage.h @@ -40,6 +40,8 @@ public: jexists = env->GetMethodID(clazz, "exists", "(Ljava/lang/String;)Z"); jaddStorage = env->GetMethodID(clazz, "addStorage", "(ZZ)Z"); jsaveScreenshot = env->GetMethodID(clazz, "saveScreenshot", "(Ljava/lang/String;[B)V"); + jimportHomeDirectory = env->GetMethodID(clazz, "importHomeDirectory", "()V"); + jexportHomeDirectory = env->GetMethodID(clazz, "exportHomeDirectory", "()V"); } bool isKnownPath(const std::string& path) override { @@ -162,6 +164,16 @@ public: checkException(); } + void importHomeDirectory() { + jni::env()->CallVoidMethod(jstorage, jimportHomeDirectory); + checkException(); + } + + void exportHomeDirectory() { + jni::env()->CallVoidMethod(jstorage, jexportHomeDirectory); + checkException(); + } + private: void checkException() { @@ -213,6 +225,8 @@ private: jmethodID jgetFileInfo; jmethodID jexists; jmethodID jsaveScreenshot; + jmethodID jexportHomeDirectory; + jmethodID jimportHomeDirectory; // FileInfo accessors lazily initialized to avoid having to load the class jmethodID jgetName = nullptr; jmethodID jgetPath = nullptr; @@ -236,6 +250,14 @@ void saveScreenshot(const std::string& name, const std::vector& data) return static_cast(customStorage()).saveScreenshot(name, data); } +void importHomeDirectory() { + static_cast(customStorage()).importHomeDirectory(); +} + +void exportHomeDirectory() { + static_cast(customStorage()).exportHomeDirectory(); +} + } // namespace hostfs extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_addStorageCallback(JNIEnv *env, jobject obj, jstring path) @@ -247,3 +269,14 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_init( { static_cast(hostfs::customStorage()).init(env, jstorage); } + +extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_AndroidStorage_reloadConfig(JNIEnv *env) +{ + if (cfgOpen()) + { + const RenderType render = config::RendererType; + config::Settings::instance().load(false); + // Make sure the renderer type doesn't change mid-flight + config::RendererType = render; + } +} diff --git a/shell/libretro/libretro_core_options.h b/shell/libretro/libretro_core_options.h index 23ef7d3c6..bff923f72 100644 --- a/shell/libretro/libretro_core_options.h +++ b/shell/libretro/libretro_core_options.h @@ -1099,8 +1099,8 @@ struct retro_core_option_v2_definition option_defs_us[] = { CORE_OPTION_NAME "_per_content_vmus", "Per-Game Visual Memory Units/Systems (VMU)", "Per-Game VMUs", - "When disabled, all games share up to 8 VMU save files (A1/A2/B1/B2/C1/C2/D1/D2) located in RetroArch's system directory.\n" - "The 'VMU A1' setting creates a unique VMU 'A1' file in RetroArch's save directory for each game that is launched.\n" + "When disabled, all games share up to 8 VMU save files (A1/A2/B1/B2/C1/C2/D1/D2) located in RetroArch's system folder.\n" + "The 'VMU A1' setting creates a unique VMU 'A1' file in RetroArch's save folder for each game that is launched.\n" "The 'All VMUs' setting creates up to 8 unique VMU files (A1/A2/B1/B2/C1/C2/D1/D2) for each game that is launched.", NULL, "vmu", From 07172416c1b3d86cf3d21ed4d6f37220236adc49 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Sat, 22 Jun 2024 15:07:05 +0200 Subject: [PATCH 10/24] android: tentative fix for touchscreen not working on android 14 Use more generic source test to detect touch events. Also get rid of Retroid Pocket 2+ specific test. Issue #1421 --- .../java/com/flycast/emulator/emu/VirtualJoystickDelegate.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java index 51d37c29f..68267cfa0 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java @@ -166,8 +166,7 @@ public class VirtualJoystickDelegate { public boolean onTouchEvent(MotionEvent event, int width, int height) { - // The Retroid Pocket 2+ is using a non-standard source - if (event.getSource() != InputDevice.SOURCE_TOUCHSCREEN && event.getSource() != 0x5002) + if ((event.getSource() & InputDevice.SOURCE_TOUCHSCREEN) != InputDevice.SOURCE_TOUCHSCREEN) // Ignore real mice, trackballs, etc. return false; JNIdc.show_osd(); From e72a867363c4ee40d603323d5f58cb477f9660b1 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Mon, 8 Jul 2024 16:49:20 +0200 Subject: [PATCH 11/24] ui: only show dreamcast bios entry if bios present --- core/ui/game_scanner.cpp | 3 +++ core/ui/gui.cpp | 24 ++++-------------------- 2 files changed, 7 insertions(+), 20 deletions(-) diff --git a/core/ui/game_scanner.cpp b/core/ui/game_scanner.cpp index 49666726e..0dba94738 100644 --- a/core/ui/game_scanner.cpp +++ b/core/ui/game_scanner.cpp @@ -150,8 +150,11 @@ void GameScanner::fetch_game_list() if (!running) break; } + std::string dcbios = hostfs::findFlash("dc_", "%bios.bin;%boot.bin"); { LockGuard _(mutex); + if (!dcbios.empty()) + game_list.insert(game_list.begin(), { "Dreamcast BIOS" }); game_list.insert(game_list.end(), arcade_game_list.begin(), arcade_game_list.end()); } if (running) diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index 6591cefb8..6ac9993e7 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -3197,25 +3197,6 @@ static void gui_display_content() ImGui::PushStyleVar(ImGuiStyleVar_ItemSpacing, ScaledVec2(8, 20)); int counter = 0; - if (gui_state != GuiState::SelectDisk && filter.PassFilter("Dreamcast BIOS")) - { - ImguiID _("bios"); - bool pressed; - if (config::BoxartDisplayMode) - { - GameMedia game; - GameBoxart art = boxart.getBoxartAndLoad(game); - ImguiFileTexture tex(art.boxartPath); - pressed = gameImageButton(tex, "Dreamcast BIOS", responsiveBoxVec2, "Dreamcast BIOS"); - } - else - { - pressed = ImGui::Selectable("Dreamcast BIOS"); - } - if (pressed) - gui_start_game(""); - counter++; - } bool gameListEmpty = false; { scanner.get_mutex().lock(); @@ -3229,6 +3210,9 @@ static void gui_display_content() && extension != "cdi" && extension != "cue") // Only dreamcast disks continue; + if (game.path.empty()) + // Dreamcast BIOS isn't a disk + continue; } std::string gameName = game.name; GameBoxart art; @@ -3239,7 +3223,7 @@ static void gui_display_content() } if (filter.PassFilter(gameName.c_str())) { - ImguiID _(game.path.c_str()); + ImguiID _(game.path.empty() ? "bios" : game.path); bool pressed = false; if (config::BoxartDisplayMode) { From 72d9ad34dc9c81ffa59a231e0c786c8104b28ec9 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Mon, 8 Jul 2024 17:15:42 +0200 Subject: [PATCH 12/24] google play: don't send changes for review automatically --- .github/workflows/android.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 85e9702a4..baa0869c7 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -50,6 +50,7 @@ jobs: releaseFiles: shell/android-studio/flycast/build/outputs/bundle/release/flycast-release.aab track: internal status: draft + changesNotSentForReview: true - name: Build dump_syms working-directory: core/deps/breakpad From 2b83df86cdbeee4786bda67be43161c114d94d06 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Sat, 20 Jul 2024 10:07:18 +0200 Subject: [PATCH 13/24] boxart: don't save database when pausing (android, uwp) Avoids ANR on android. Unnecessary since database is now saved regularly. --- core/sdl/sdl.cpp | 1 - core/ui/boxart/boxart.cpp | 12 ++++++++---- core/ui/boxart/boxart.h | 3 ++- core/ui/gui.cpp | 7 +------ core/ui/gui.h | 1 - .../flycast/src/main/jni/src/Android.cpp | 1 - shell/apple/emulator-ios/emulator/AppDelegate.mm | 1 - 7 files changed, 11 insertions(+), 15 deletions(-) diff --git a/core/sdl/sdl.cpp b/core/sdl/sdl.cpp index 215843642..9027dfa25 100644 --- a/core/sdl/sdl.cpp +++ b/core/sdl/sdl.cpp @@ -766,7 +766,6 @@ static int suspendEventFilter(void *userdata, SDL_Event *event) { if (event->type == SDL_APP_WILLENTERBACKGROUND) { - gui_save(); if (gameRunning) { try { diff --git a/core/ui/boxart/boxart.cpp b/core/ui/boxart/boxart.cpp index dc3f42c32..72c4e4a2d 100644 --- a/core/ui/boxart/boxart.cpp +++ b/core/ui/boxart/boxart.cpp @@ -146,14 +146,12 @@ void Boxart::fetchBoxart() } } } - saveDatabase(true); + saveDatabase(); }); } -void Boxart::saveDatabase(bool internal) +void Boxart::saveDatabase() { - if (!internal && fetching.valid()) - fetching.get(); if (!databaseDirty) return; std::string db_name = getSaveDirectory() + DB_NAME; @@ -216,3 +214,9 @@ void Boxart::loadDatabase() WARN_LOG(COMMON, "Corrupted database file: %s", e.what()); } } + +void Boxart::term() +{ + if (fetching.valid()) + fetching.get(); +} diff --git a/core/ui/boxart/boxart.h b/core/ui/boxart/boxart.h index 2f91dc424..24bcc03b2 100644 --- a/core/ui/boxart/boxart.h +++ b/core/ui/boxart/boxart.h @@ -34,10 +34,11 @@ class Boxart public: GameBoxart getBoxartAndLoad(const GameMedia& media); GameBoxart getBoxart(const GameMedia& media); - void saveDatabase(bool internal = false); + void term(); private: void loadDatabase(); + void saveDatabase(); std::string getSaveDirectory() const { return get_writable_data_path("/boxart/"); } diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index 6ac9993e7..add088f2b 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -3681,7 +3681,7 @@ void gui_term() EventManager::unlisten(Event::Resume, emuEventCallback); EventManager::unlisten(Event::Start, emuEventCallback); EventManager::unlisten(Event::Terminate, emuEventCallback); - gui_save(); + boxart.term(); } } @@ -3717,11 +3717,6 @@ void gui_error(const std::string& what) error_msg = what; } -void gui_save() -{ - boxart.saveDatabase(); -} - void gui_loadState() { const LockGuard lock(guiMutex); diff --git a/core/ui/gui.h b/core/ui/gui.h index 9c23c38b0..0bb8f645e 100644 --- a/core/ui/gui.h +++ b/core/ui/gui.h @@ -47,7 +47,6 @@ void gui_stop_game(const std::string& message = ""); void gui_start_game(const std::string& path); void gui_error(const std::string& what); void gui_setOnScreenKeyboardCallback(void (*callback)(bool show)); -void gui_save(); void gui_loadState(); void gui_saveState(bool stopRestart = true); std::string gui_getCurGameBoxartUrl(); diff --git a/shell/android-studio/flycast/src/main/jni/src/Android.cpp b/shell/android-studio/flycast/src/main/jni/src/Android.cpp index b8a261e75..0342e99f1 100644 --- a/shell/android-studio/flycast/src/main/jni/src/Android.cpp +++ b/shell/android-studio/flycast/src/main/jni/src/Android.cpp @@ -290,7 +290,6 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_emu_JNIdc_pause(JNIE if (config::AutoSaveState) dc_savestate(config::SavestateSlot); } - gui_save(); } extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_emu_JNIdc_resume(JNIEnv *env,jobject obj) diff --git a/shell/apple/emulator-ios/emulator/AppDelegate.mm b/shell/apple/emulator-ios/emulator/AppDelegate.mm index 592f5311e..34840d42a 100644 --- a/shell/apple/emulator-ios/emulator/AppDelegate.mm +++ b/shell/apple/emulator-ios/emulator/AppDelegate.mm @@ -80,7 +80,6 @@ static bool emulatorRunning; { // Use this method to release shared resources, save user data, invalidate timers, and store enough application state information to restore your application to its current state in case it is terminated later. // If your application supports background execution, this method is called instead of applicationWillTerminate: when the user quits. - gui_save(); if (config::AutoSaveState && !settings.content.path.empty()) dc_savestate(config::SavestateSlot); } From 8b2c1bc5dc9a825dca334b16e73d60f1a4a9232f Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Sat, 20 Jul 2024 10:30:27 +0200 Subject: [PATCH 14/24] android: bug fixes onInputDeviceAdded may be called with an invalid device id. Avoid NPE onGenericMotionEvent: event may have a null device. Avoid NPE getExternalFilesDir may return null if no external storage available. Use getFilesDir in this case. Catch exceptions in HomeMover and continue. addStorage: catch SecurityException and alert user. Graceful failure. --- .../com/flycast/emulator/AndroidStorage.java | 24 ++++++++++++++++++- .../com/flycast/emulator/BaseGLActivity.java | 13 ++++++---- .../java/com/flycast/emulator/HomeMover.java | 13 ++++++++-- .../emulator/periph/InputDeviceManager.java | 2 +- 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java index 9685b1e9f..20f092925 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/AndroidStorage.java @@ -19,9 +19,11 @@ package com.flycast.emulator; import android.app.Activity; +import android.app.AlertDialog; import android.content.ContentResolver; import android.content.ContentUris; import android.content.CursorLoader; +import android.content.DialogInterface; import android.content.Intent; import android.database.Cursor; import android.media.MediaScannerConnection; @@ -76,7 +78,27 @@ public class AndroidStorage { } else { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) - activity.getContentResolver().takePersistableUriPermission(uri, storageIntentPerms); + { + try { + activity.getContentResolver().takePersistableUriPermission(uri, storageIntentPerms); + } catch (SecurityException e) { + Log.w("Flycast", "takePersistableUriPermission failed", e); + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(activity); + dlgAlert.setMessage("Can't get permissions to access this folder.\nPlease select a different one."); + dlgAlert.setTitle("Storage Error"); + dlgAlert.setPositiveButton("Ok", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + addStorageCallback(null); + } + }); + dlgAlert.setIcon(android.R.drawable.ic_dialog_alert); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + return; + } + } if (Build.VERSION.SDK_INT < Build.VERSION_CODES.LOLLIPOP) { String realPath = getRealPath(uri); if (realPath != null) { diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java index 2b617d530..603a58414 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java @@ -270,7 +270,10 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. } @Override public boolean onGenericMotionEvent(MotionEvent event) { - if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK && event.getAction() == MotionEvent.ACTION_MOVE) { + if ((event.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK + && event.getAction() == MotionEvent.ACTION_MOVE + && event.getDevice() != null) + { List axes = event.getDevice().getMotionRanges(); boolean rc = false; for (InputDevice.MotionRange range : axes) @@ -416,9 +419,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. } - private String getDefaultHomeDir() - { - return getExternalFilesDir(null).getAbsolutePath(); + private String getDefaultHomeDir() { + File dir = getExternalFilesDir(null); + if (dir == null) + dir = getFilesDir(); + return dir.getAbsolutePath(); } private String checkHomeDirectory(String homeDir) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java index 0bd030a86..0256f6d6f 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/HomeMover.java @@ -55,8 +55,13 @@ public class HomeMover { File f = getFile(parent); if (f != null) return new File(f, kid).toURI().toString(); - else - return storage.getSubPath(parent, kid); + else { + try { + return storage.getSubPath(parent, kid); + } catch (RuntimeException e) { + return null; + } + } } public FileInfo[] listContent(String folder) @@ -97,6 +102,8 @@ public class HomeMover { } public boolean exists(String path) { + if (path == null) + return false; File file = getFile(path); if (file != null) return file.exists(); @@ -139,6 +146,8 @@ public class HomeMover { Thread thread = new Thread(new Runnable() { private void copyFile(String path, String name, String toDir) { + if (path == null) + return; //Log.d("flycast", "Copying " + path + " to " + toDir); try { InputStream in = wrapper.openInputStream(path); diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java index f9a2bd95f..65ef5e60a 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java @@ -61,7 +61,7 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene @Override public void onInputDeviceAdded(int i) { InputDevice device = InputDevice.getDevice(i); - if ((device.getSources() & InputDevice.SOURCE_CLASS_BUTTON) == InputDevice.SOURCE_CLASS_BUTTON) { + if (device != null && (device.getSources() & InputDevice.SOURCE_CLASS_BUTTON) == InputDevice.SOURCE_CLASS_BUTTON) { int port = 0; if ((device.getSources() & InputDevice.SOURCE_CLASS_JOYSTICK) == InputDevice.SOURCE_CLASS_JOYSTICK) { port = this.maple_port == 3 ? 3 : this.maple_port++; From 33d1dafdb7294c083cb261438648f2c30673d3fc Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Sat, 20 Jul 2024 10:58:26 +0200 Subject: [PATCH 15/24] android: revert changesNotSentForReview param change Probably due to the current weather conditions at google's HQ --- .github/workflows/android.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index baa0869c7..85e9702a4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -50,7 +50,6 @@ jobs: releaseFiles: shell/android-studio/flycast/build/outputs/bundle/release/flycast-release.aab track: internal status: draft - changesNotSentForReview: true - name: Build dump_syms working-directory: core/deps/breakpad From a058481e3610da9f2417d3ff6899eaa01ac631d2 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Tue, 13 Aug 2024 17:18:31 +0200 Subject: [PATCH 16/24] oboe: log record stream buffer capacity, not output stream Fixes MINIDUMP-43E --- core/audio/audiobackend_oboe.cpp | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/core/audio/audiobackend_oboe.cpp b/core/audio/audiobackend_oboe.cpp index 2843b7468..9bf7609c5 100644 --- a/core/audio/audiobackend_oboe.cpp +++ b/core/audio/audiobackend_oboe.cpp @@ -176,7 +176,7 @@ public: } recordStream->requestStart(); NOTICE_LOG(AUDIO, "Oboe recorder started. stream capacity: %d frames", - stream->getBufferCapacityInFrames()); + recordStream->getBufferCapacityInFrames()); return true; } From 9fd80b952aed55babfb5eb638c2ca9bc46aad86f Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Tue, 13 Aug 2024 17:38:23 +0200 Subject: [PATCH 17/24] Storage::getSubPath can throw if path doesn't exist (android) --- core/hw/naomi/gdcartridge.cpp | 4 ++-- core/hw/naomi/naomi_cart.cpp | 16 +++++++++++----- core/hw/naomi/systemsp.cpp | 14 ++++++++++---- core/oslib/oslib.cpp | 9 ++++++--- 4 files changed, 29 insertions(+), 14 deletions(-) diff --git a/core/hw/naomi/gdcartridge.cpp b/core/hw/naomi/gdcartridge.cpp index cdd00354f..357a9c965 100644 --- a/core/hw/naomi/gdcartridge.cpp +++ b/core/hw/naomi/gdcartridge.cpp @@ -494,9 +494,9 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector *digest) u8 buffer[2048]; std::string parent = hostfs::storage().getParentPath(settings.content.path); std::string gdrom_path = get_file_basename(settings.content.fileName) + "/" + gdrom_name; - gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path); std::unique_ptr gdrom; try { + gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path); gdrom = std::unique_ptr(OpenDisc(gdrom_path + ".chd", digest)); } catch (const FlycastException& e) @@ -504,8 +504,8 @@ void GDCartridge::device_start(LoadProgress *progress, std::vector *digest) WARN_LOG(NAOMI, "Opening chd failed: %s", e.what()); if (gdrom_parent_name != nullptr) { - std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(gdrom_parent_name) + "/" + gdrom_name); try { + std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(gdrom_parent_name) + "/" + gdrom_name); gdrom = std::unique_ptr(OpenDisc(gdrom_parent_path + ".chd", digest)); } catch (const FlycastException& e) { WARN_LOG(NAOMI, "Opening parent chd failed: %s", e.what()); diff --git a/core/hw/naomi/naomi_cart.cpp b/core/hw/naomi/naomi_cart.cpp index ae0757297..45c7e37bb 100644 --- a/core/hw/naomi/naomi_cart.cpp +++ b/core/hw/naomi/naomi_cart.cpp @@ -181,9 +181,12 @@ void naomi_cart_LoadBios(const char *filename) std::unique_ptr parent_archive; if (game->parent_name != nullptr) { - std::string parentPath = hostfs::storage().getParentPath(filename); - parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name); - parent_archive.reset(OpenArchive(parentPath)); + try { + std::string parentPath = hostfs::storage().getParentPath(filename); + parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name); + parent_archive.reset(OpenArchive(parentPath)); + } catch (const FlycastException& e) { + } } const char *bios = "naomi"; @@ -219,8 +222,11 @@ static void loadMameRom(const std::string& path, const std::string& fileName, Lo if (game->parent_name != nullptr) { std::string parentPath = hostfs::storage().getParentPath(path); - parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name); - parent_archive.reset(OpenArchive(parentPath)); + try { + parentPath = hostfs::storage().getSubPath(parentPath, game->parent_name); + parent_archive.reset(OpenArchive(parentPath)); + } catch (const FlycastException& e) { + } if (parent_archive != nullptr) INFO_LOG(NAOMI, "Opened %s", game->parent_name); else diff --git a/core/hw/naomi/systemsp.cpp b/core/hw/naomi/systemsp.cpp index 9cc51b366..02935dd10 100644 --- a/core/hw/naomi/systemsp.cpp +++ b/core/hw/naomi/systemsp.cpp @@ -2088,12 +2088,18 @@ void SystemSpCart::Init(LoadProgress *progress, std::vector *digest) { std::string parent = hostfs::storage().getParentPath(settings.content.path); std::string gdrom_path = get_file_basename(settings.content.fileName) + "/" + std::string(mediaName) + ".chd"; - gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path); - chd = openChd(gdrom_path); + try { + gdrom_path = hostfs::storage().getSubPath(parent, gdrom_path); + chd = openChd(gdrom_path); + } catch (const FlycastException& e) { + } if (parentName != nullptr && chd == nullptr) { - std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(parentName) + "/" + std::string(mediaName) + ".chd"); - chd = openChd(gdrom_parent_path); + try { + std::string gdrom_parent_path = hostfs::storage().getSubPath(parent, std::string(parentName) + "/" + std::string(mediaName) + ".chd"); + chd = openChd(gdrom_parent_path); + } catch (const FlycastException& e) { + } } if (chd == nullptr) throw NaomiCartException("SystemSP: Cannot open CompactFlash file " + gdrom_path); diff --git a/core/oslib/oslib.cpp b/core/oslib/oslib.cpp index 025f63b30..05d33657d 100644 --- a/core/oslib/oslib.cpp +++ b/core/oslib/oslib.cpp @@ -82,9 +82,12 @@ std::string findFlash(const std::string& prefix, const std::string& names) return fullpath; for (const auto& path : config::ContentPath.get()) { - fullpath = hostfs::storage().getSubPath(path, name); - if (hostfs::storage().exists(fullpath)) - return fullpath; + try { + fullpath = hostfs::storage().getSubPath(path, name); + if (hostfs::storage().exists(fullpath)) + return fullpath; + } catch (const hostfs::StorageException& e) { + } } start = semicolon; From 138adc28d70ff78804c5f107ef8a66b47df73461 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Tue, 13 Aug 2024 17:41:40 +0200 Subject: [PATCH 18/24] android: ANR when loading cheat file --- core/ui/gui.cpp | 6 +++++- core/ui/gui.h | 2 ++ core/ui/gui_cheats.cpp | 4 +++- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index add088f2b..270ad1156 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -3774,11 +3774,15 @@ std::string gui_getCurGameBoxartUrl() return art.boxartUrl; } +void gui_runOnUiThread(std::function function) { + uiThreadRunner.runOnThread(function); +} + void gui_takeScreenshot() { if (!game_started) return; - uiThreadRunner.runOnThread([]() { + gui_runOnUiThread([]() { std::string date = timeToISO8601(time(nullptr)); std::replace(date.begin(), date.end(), '/', '-'); std::replace(date.begin(), date.end(), ':', '-'); diff --git a/core/ui/gui.h b/core/ui/gui.h index 0bb8f645e..80be4f5e6 100644 --- a/core/ui/gui.h +++ b/core/ui/gui.h @@ -20,6 +20,7 @@ #include "types.h" #include +#include void gui_init(); void gui_initFonts(); @@ -51,6 +52,7 @@ void gui_loadState(); void gui_saveState(bool stopRestart = true); std::string gui_getCurGameBoxartUrl(); void gui_takeScreenshot(); +void gui_runOnUiThread(std::function function); enum class GuiState { Closed, diff --git a/core/ui/gui_cheats.cpp b/core/ui/gui_cheats.cpp index b10faa6f0..c04a91bd5 100644 --- a/core/ui/gui_cheats.cpp +++ b/core/ui/gui_cheats.cpp @@ -75,7 +75,9 @@ static void addCheat() static void cheatFileSelected(bool cancelled, std::string path) { if (!cancelled) - cheatManager.loadCheatFile(path); + gui_runOnUiThread([path]() { + cheatManager.loadCheatFile(path); + }); } void gui_cheats() From 09ab139407b6ce9b3bfbeaeafbd7b51faae1863b Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Thu, 15 Aug 2024 11:55:07 +0200 Subject: [PATCH 19/24] android: deadlock when Emulator::checkStatus is called concurrently If checkStatus(wait = true) is called, any other thread will be blocked even when calling with wait = false since the mutex is locked. This happens on Android when JNIdc.rendinitNative(null) is called by the app main thread. Use a shared_future instead and unlock the mutex before waiting. --- core/emulator.cpp | 13 +++++++++---- core/emulator.h | 2 +- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/core/emulator.cpp b/core/emulator.cpp index e39fc19d4..1ed9a22dc 100644 --- a/core/emulator.cpp +++ b/core/emulator.cpp @@ -908,15 +908,20 @@ void Emulator::start() bool Emulator::checkStatus(bool wait) { try { - const std::lock_guard lock(mutex); + std::unique_lock lock(mutex); if (threadResult.valid()) { - if (!wait) - { - auto result = threadResult.wait_for(std::chrono::seconds(0)); + lock.unlock(); + auto localResult = threadResult; + if (wait) { + localResult.wait(); + } + else { + auto result = localResult.wait_for(std::chrono::seconds(0)); if (result == std::future_status::timeout) return true; } + lock.lock(); threadResult.get(); } return false; diff --git a/core/emulator.h b/core/emulator.h index 6802b00e8..eb2a698be 100644 --- a/core/emulator.h +++ b/core/emulator.h @@ -179,7 +179,7 @@ private: Terminated, }; State state = Uninitialized; - std::future threadResult; + std::shared_future threadResult; bool resetRequested = false; bool singleStep = false; u64 startTime = 0; From dae3676979b53b1b60cd129b3eb31399bb1ed714 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Thu, 15 Aug 2024 11:58:45 +0200 Subject: [PATCH 20/24] android: NPE in NativeGLView when Emulator.currentActivity is null --- .../main/java/com/flycast/emulator/emu/NativeGLView.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java index 2a04e13c8..0a928ac7c 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/NativeGLView.java @@ -17,6 +17,7 @@ import android.view.View; import android.view.WindowInsets; import android.view.WindowManager; +import com.flycast.emulator.BaseGLActivity; import com.flycast.emulator.Emulator; import com.flycast.emulator.periph.InputDeviceManager; @@ -126,7 +127,9 @@ public class NativeGLView extends SurfaceView implements SurfaceHolder.Callback Log.i("flycast", "NativeGLView.surfaceChanged: " + w + "x" + h); surfaceReady = true; JNIdc.rendinitNative(surfaceHolder.getSurface(), w, h); - Emulator.getCurrentActivity().handleStateChange(false); + BaseGLActivity activity = Emulator.getCurrentActivity(); + if (activity != null) + activity.handleStateChange(false); } @Override @@ -134,7 +137,9 @@ public class NativeGLView extends SurfaceView implements SurfaceHolder.Callback Log.i("flycast", "NativeGLView.surfaceDestroyed"); surfaceReady = false; JNIdc.rendinitNative(null, 0, 0); - Emulator.getCurrentActivity().handleStateChange(true); + BaseGLActivity activity = Emulator.getCurrentActivity(); + if (activity != null) + activity.handleStateChange(true); } public boolean isSurfaceReady() { From aafa9f0bf03b95e3b60cbd69370286a0a05fc328 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Thu, 15 Aug 2024 12:09:27 +0200 Subject: [PATCH 21/24] android: intent url ignored unless external storage legacy is enabled Issue #1584 Accept fileDir (internal storage) as home folder in case it was created without external storage. --- .../main/java/com/flycast/emulator/BaseGLActivity.java | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java index 603a58414..3c0555933 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/BaseGLActivity.java @@ -138,10 +138,11 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. //Log.i("flycast", "External storage legacy: " + (externalStorageLegacy ? "preserved" : "lost")); } if (!storagePermissionGranted) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M) + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.M || !externalStorageLegacy) // No permission needed before Android 6 + // Permissions only needed in legacy external storage mode storagePermissionGranted = true; - else if (externalStorageLegacy) { + else { Log.i("flycast", "Asking for external storage permission"); ActivityCompat.requestPermissions(this, new String[]{ @@ -431,7 +432,8 @@ public abstract class BaseGLActivity extends Activity implements ActivityCompat. if (homeDir.isEmpty()) // home dir not set: use default return getDefaultHomeDir(); - if (homeDir.startsWith(getDefaultHomeDir())) + // must account for the fact that homeDir may be on internal storage but external storage is now available + if (homeDir.startsWith(getDefaultHomeDir()) || homeDir.startsWith(getFilesDir().getAbsolutePath())) // home dir is ok return homeDir; if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.P) From f1fd8b12e2f0428d4dea31e80ca89db09f8fddf5 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Thu, 15 Aug 2024 17:25:02 +0200 Subject: [PATCH 22/24] android: do all rumble/haptic on background thread Haptic effects for the virtual gamepad must be done on a background thread. Not sure about gamepad rumble but it shouldn't hurt. Reuse haptic duration for haptic power (0-100). Fix crash when power is zero (createOneShot needs power >= 1) --- core/ui/gui.cpp | 2 +- .../java/com/flycast/emulator/Emulator.java | 6 +-- .../emulator/periph/InputDeviceManager.java | 51 +++++++++++-------- .../emulator/periph/VibratorThread.java | 19 ++++--- 4 files changed, 45 insertions(+), 33 deletions(-) diff --git a/core/ui/gui.cpp b/core/ui/gui.cpp index 270ad1156..58291ad8c 100644 --- a/core/ui/gui.cpp +++ b/core/ui/gui.cpp @@ -1441,7 +1441,7 @@ static void gamepadSettingsPopup(const std::shared_ptr& gamepad) if (gamepad->is_virtual_gamepad()) { header("Haptic"); - OptionSlider("Power", config::VirtualGamepadVibration, 0, 60, "Haptic feedback power"); + OptionSlider("Power", config::VirtualGamepadVibration, 0, 100, "Haptic feedback power", "%d%%"); } else if (gamepad->is_rumble_enabled()) { diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java index 1bddf2a31..28d8ba14e 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/Emulator.java @@ -22,7 +22,7 @@ public class Emulator extends Application { public static final int MDT_Microphone = 2; public static final int MDT_None = 8; - public static int vibrationDuration = 20; + public static int vibrationPower = 80; public static int[] maple_devices = { MDT_None, @@ -42,7 +42,7 @@ public class Emulator extends Application { * */ public void getConfigurationPrefs() { - Emulator.vibrationDuration = JNIdc.getVirtualGamepadVibration(); + Emulator.vibrationPower = JNIdc.getVirtualGamepadVibration(); JNIdc.getControllers(maple_devices, maple_expansion_devices); } @@ -54,7 +54,7 @@ public class Emulator extends Application { { Log.i("flycast", "SaveAndroidSettings: saving preferences"); SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); - Emulator.vibrationDuration = JNIdc.getVirtualGamepadVibration(); + Emulator.vibrationPower = JNIdc.getVirtualGamepadVibration(); JNIdc.getControllers(maple_devices, maple_expansion_devices); prefs.edit() diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java index 65ef5e60a..a8078f063 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/InputDeviceManager.java @@ -105,12 +105,31 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene } } + private void vibrate(Vibrator vibrator, long duration_ms, float power) + { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + int ipow = Math.min((int)(power * 255), 255); + if (ipow >= 1) + vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, ipow)); + else + vibrator.cancel(); + } + else + vibrator.vibrate(duration_ms); + } + // Called from native code + // returns false if the device has no vibrator private boolean rumble(int i, float power, float inclination, int duration_ms) { Vibrator vibrator = getVibrator(i); if (vibrator == null) return false; + if (i == VIRTUAL_GAMEPAD_ID) { + if (Emulator.vibrationPower == 0) + return true; + power *= Emulator.vibrationPower / 100.f; + } VibrationParams params; synchronized (this) { @@ -120,25 +139,15 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene vibParams.put(i, params); } } - if (power == 0) { - if (params.power != 0) - vibrator.cancel(); - } else { - if (inclination > 0) { - params.inclination = inclination * power; - params.stopTime = System.currentTimeMillis() + duration_ms; - } - else { - params.inclination = 0; - } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - vibrator.vibrate(VibrationEffect.createOneShot(duration_ms, (int)(power * 255))); - else - vibrator.vibrate(duration_ms); + if (power != 0) { + params.stopTime = System.currentTimeMillis() + duration_ms; if (inclination > 0) - VibratorThread.getInstance().setVibrating(); + params.inclination = inclination * power; + else + params.inclination = 0; } params.power = power; + VibratorThread.getInstance().setVibrating(); return true; } @@ -164,19 +173,19 @@ public final class InputDeviceManager implements InputManager.InputDeviceListene synchronized (this) { params = vibParams.get(i); } - if (vibrator == null || params == null || params.power == 0 || params.inclination == 0) + if (vibrator == null || params == null) return false; long remTime = params.stopTime - System.currentTimeMillis(); - if (remTime <= 0) { + if (remTime <= 0 || params.power == 0) { params.power = 0; params.inclination = 0; vibrator.cancel(); return false; } - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) - vibrator.vibrate(VibrationEffect.createOneShot(remTime, (int)(params.inclination * remTime * 255))); + if (params.inclination > 0) + vibrate(vibrator, remTime, params.inclination * remTime); else - vibrator.vibrate(remTime); + vibrate(vibrator, remTime, params.power); return true; } diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java index b7e7b1051..9b7068c7d 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/periph/VibratorThread.java @@ -36,6 +36,7 @@ public class VibratorThread extends Thread { private VibrationEffect clickEffect = null; int clickDuration = 0; private static VibratorThread INSTANCE = null; + private static final int LEGACY_VIBRATION_DURATION = 20; // ms public static VibratorThread getInstance() { synchronized (VibratorThread.class) { @@ -52,7 +53,11 @@ public class VibratorThread extends Thread { private Vibrator getVibrator(int i) { if (i == InputDeviceManager.VIRTUAL_GAMEPAD_ID) { - return (Vibrator) Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE); + if (Emulator.vibrationPower > 0) + return (Vibrator) Emulator.getAppContext().getSystemService(Context.VIBRATOR_SERVICE); + else + // vibration disabled + return null; } else { InputDevice device = InputDevice.getDevice(i); @@ -111,7 +116,7 @@ public class VibratorThread extends Thread { } public void click() { - if (Emulator.vibrationDuration > 0) { + if (Emulator.vibrationPower > 0) { synchronized (this) { click = true; notify(); @@ -126,17 +131,16 @@ public class VibratorThread extends Thread { return; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - if (clickEffect == null || clickDuration != Emulator.vibrationDuration) + if (clickEffect == null) { - clickDuration = Emulator.vibrationDuration; if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) clickEffect = VibrationEffect.createPredefined(VibrationEffect.EFFECT_HEAVY_CLICK); else - clickEffect = VibrationEffect.createOneShot(clickDuration, VibrationEffect.DEFAULT_AMPLITUDE); + clickEffect = VibrationEffect.createOneShot(LEGACY_VIBRATION_DURATION, VibrationEffect.DEFAULT_AMPLITUDE); } vibrator.vibrate(clickEffect); } else { - vibrator.vibrate(Emulator.vibrationDuration); + vibrator.vibrate(LEGACY_VIBRATION_DURATION); } } @@ -144,8 +148,7 @@ public class VibratorThread extends Thread { { // FIXME possible race condition synchronized (this) { - if (nextRumbleUpdate == 0) - nextRumbleUpdate = System.currentTimeMillis() + 16667; + nextRumbleUpdate = 1; notify(); } } From 6679cb2f3158eb7bd757801524bc4a534ae214f3 Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 16 Aug 2024 10:17:06 +0200 Subject: [PATCH 23/24] android: show/hide keyboard in main thread to avoid deadlock Tentative fix for deadlock when main thread is calling JNIdc.rendinitNative(null), and render thread is blocked on a lock trying to hide on-screen keyboard. --- .../flycast/emulator/NativeGLActivity.java | 27 +++++++------ .../flycast/src/main/jni/src/Android.cpp | 38 +++++++------------ 2 files changed, 29 insertions(+), 36 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java index 86408aceb..7041eb70d 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/NativeGLActivity.java @@ -147,13 +147,11 @@ public final class NativeGLActivity extends BaseGLActivity { } } - // Called from native code - public void showTextInput(int x, int y, int w, int h) { + private void showTextInput(int x, int y, int w, int h) { // Transfer the task to the main thread as a Runnable handler.post(new ShowTextInputTask(x, y, w, h)); } - // Called from native code public void hideTextInput() { Log.d("flycast", "hideTextInput " + (mTextEdit != null ? "mTextEdit != null" : "")); if (mTextEdit != null) { @@ -177,15 +175,22 @@ public final class NativeGLActivity extends BaseGLActivity { } // Called from native code - public boolean isScreenKeyboardShown() { - if (mTextEdit == null) - return false; + public void showScreenKeyboard(boolean show) { + handler.post(new Runnable() { + @Override + public void run() { + if (!show && (mTextEdit == null || !mScreenKeyboardShown)) + return; - if (!mScreenKeyboardShown) - return false; - - InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); - return imm.isAcceptingText(); + InputMethodManager imm = (InputMethodManager) getSystemService(Context.INPUT_METHOD_SERVICE); + if (show != imm.isAcceptingText()) { + if (show) + showTextInput(0, 0, 16, 100); + else + hideTextInput(); + } + } + }); } } diff --git a/shell/android-studio/flycast/src/main/jni/src/Android.cpp b/shell/android-studio/flycast/src/main/jni/src/Android.cpp index 0342e99f1..09146fde7 100644 --- a/shell/android-studio/flycast/src/main/jni/src/Android.cpp +++ b/shell/android-studio/flycast/src/main/jni/src/Android.cpp @@ -71,9 +71,7 @@ static jobject g_activity; static jmethodID VJoyStartEditingMID; static jmethodID VJoyStopEditingMID; static jmethodID VJoyResetEditingMID; -static jmethodID showTextInputMid; -static jmethodID hideTextInputMid; -static jmethodID isScreenKeyboardShownMid; +static jmethodID showScreenKeyboardMid; static jmethodID onGameStateChangeMid; static void emuEventCallback(Event event, void *) @@ -495,17 +493,8 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa keyboard = std::make_shared(); GamepadDevice::Register(keyboard); gui_setOnScreenKeyboardCallback([](bool show) { - if (g_activity == nullptr) - return; - JNIEnv *env = jni::env(); - if (show != env->CallBooleanMethod(g_activity, isScreenKeyboardShownMid)) - { - INFO_LOG(INPUT, "show/hide keyboard %d", show); - if (show) - env->CallVoidMethod(g_activity, showTextInputMid, 0, 0, 16, 100); - else - env->CallVoidMethod(g_activity, hideTextInputMid); - } + if (g_activity != nullptr) + jni::env()->CallVoidMethod(g_activity, showScreenKeyboardMid, show); }); } @@ -582,20 +571,19 @@ extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_periph_InputDeviceMa extern "C" JNIEXPORT void JNICALL Java_com_flycast_emulator_BaseGLActivity_register(JNIEnv *env, jobject obj, jobject activity) { - if (g_activity != NULL) - { + if (g_activity != nullptr) { env->DeleteGlobalRef(g_activity); - g_activity = NULL; + g_activity = nullptr; } - if (activity != NULL) { + if (activity != nullptr) + { g_activity = env->NewGlobalRef(activity); - VJoyStartEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyStartEditing", "()V"); - VJoyStopEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyStopEditing", "(Z)V"); - VJoyResetEditingMID = env->GetMethodID(env->GetObjectClass(activity), "VJoyResetEditing", "()V"); - showTextInputMid = env->GetMethodID(env->GetObjectClass(activity), "showTextInput", "(IIII)V"); - hideTextInputMid = env->GetMethodID(env->GetObjectClass(activity), "hideTextInput", "()V"); - isScreenKeyboardShownMid = env->GetMethodID(env->GetObjectClass(activity), "isScreenKeyboardShown", "()Z"); - onGameStateChangeMid = env->GetMethodID(env->GetObjectClass(activity), "onGameStateChange", "(Z)V"); + jclass actClass = env->GetObjectClass(activity); + VJoyStartEditingMID = env->GetMethodID(actClass, "VJoyStartEditing", "()V"); + VJoyStopEditingMID = env->GetMethodID(actClass, "VJoyStopEditing", "(Z)V"); + VJoyResetEditingMID = env->GetMethodID(actClass, "VJoyResetEditing", "()V"); + showScreenKeyboardMid = env->GetMethodID(actClass, "showScreenKeyboard", "(Z)V"); + onGameStateChangeMid = env->GetMethodID(actClass, "onGameStateChange", "(Z)V"); } } From 8ea12443d3a6a1cd82c30ab5d3c33b2b0560578c Mon Sep 17 00:00:00 2001 From: Flyinghead Date: Fri, 16 Aug 2024 13:12:27 +0200 Subject: [PATCH 24/24] android: fix touchscreen coords when view is scaled Samsung phones/tablets can scale down the emulator view to improve performance. In this case, getPointerCoords() returns screen, not view coordinates. Use MotionEvent.getX/Y() instead. --- .../emulator/emu/VirtualJoystickDelegate.java | 14 +++++--------- 1 file changed, 5 insertions(+), 9 deletions(-) diff --git a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java index 68267cfa0..1b23cf0f0 100644 --- a/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java +++ b/shell/android-studio/flycast/src/main/java/com/flycast/emulator/emu/VirtualJoystickDelegate.java @@ -342,10 +342,8 @@ public class VirtualJoystickDelegate { } else { - MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords(); - event.getPointerCoords(0, pointerCoords); - mouse_pos[0] = Math.round(pointerCoords.x); - mouse_pos[1] = Math.round(pointerCoords.y); + mouse_pos[0] = Math.round(event.getX()); + mouse_pos[1] = Math.round(event.getY()); mouse_btns = MotionEvent.BUTTON_PRIMARY; // Mouse left button down } break; @@ -353,10 +351,8 @@ public class VirtualJoystickDelegate { case MotionEvent.ACTION_MOVE: if (event.getPointerCount() == 1) { - MotionEvent.PointerCoords pointerCoords = new MotionEvent.PointerCoords(); - event.getPointerCoords(0, pointerCoords); - mouse_pos[0] = Math.round(pointerCoords.x); - mouse_pos[1] = Math.round(pointerCoords.y); + mouse_pos[0] = Math.round(event.getX()); + mouse_pos[1] = Math.round(event.getY()); } break; } @@ -365,7 +361,7 @@ public class VirtualJoystickDelegate { InputDeviceManager.getInstance().virtualGamepadEvent(rv, joyx, joyy, left_trigger, right_trigger, fastForward); // Only register the mouse event if no virtual gamepad button is down if ((!editVjoyMode && rv == 0xFFFFFFFF && left_trigger == 0 && right_trigger == 0 && joyx == 0 && joyy == 0 && !fastForward) - || JNIdc.guiIsOpen()) + || JNIdc.guiIsOpen()) InputDeviceManager.getInstance().mouseEvent(mouse_pos[0], mouse_pos[1], mouse_btns); return(true); }