diff --git a/.editorconfig b/.editorconfig old mode 100755 new mode 100644 diff --git a/.gitattributes b/.gitattributes index 2e62a4e59..06b76c6c8 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,2 +1,5 @@ +# Forces all files which git considers text files to use LF line endings +* text=auto eol=lf + *.cpp diff=cpp *.h diff=cpp diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index e7db7e8a8..51a7eae63 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -36,6 +36,9 @@ Contributions are welcome! Here's how you can help: - Follow the [C/C++](http://dev.minetest.net/Code_style_guidelines) and [Lua](http://dev.minetest.net/Lua_code_style_guidelines) code style guidelines. - Check your code works as expected and document any changes to the Lua API. + - To avoid conflicting changes between contributions, do not do the following manually. They will be done before each release. + - Run `updatepo.sh` or update `minetest.po{,t}` even if your code adds new translatable strings. + - Update `minetest.conf.example` and `settings_translation_file.cpp` even if your code adds new core settings. 4. Commit & [push](https://help.github.com/articles/pushing-to-a-remote/) your changes to a new branch (not `master`, one change per branch) - Commit messages should: @@ -67,20 +70,6 @@ Contributions are welcome! Here's how you can help: might need more work in the future. 5. It uses protocols and formats which include the required compatibility. -### Important note about automated GitHub checks - -When you submit a pull request, GitHub automatically runs checks on the Minetest -Engine combined with your changes. One of these checks is called 'cpp lint / -clang format', which checks code formatting. Because formatting for readability -requires human judgement this check often fails and often makes unsuitable -formatting requests which make code readability worse. - -If this check fails, look at the details to check for any clear mistakes and -correct those. However, you should not apply everything ClangFormat requests. -Ignore requests that make code readability worse and any other clearly -unsuitable requests. Discuss in the pull request with a core developer about how -to progress. - ## Issues If you experience an issue, we would like to know the details - especially when diff --git a/.github/ISSUE_TEMPLATE/bug_report.yaml b/.github/ISSUE_TEMPLATE/bug_report.yaml index 24cee5aae..32b7f5673 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yaml +++ b/.github/ISSUE_TEMPLATE/bug_report.yaml @@ -15,11 +15,10 @@ body: label: Minetest version description: | Paste the Minetest version below. - If you are on a devel version, please add a git commit hash. - You can use `minetest --version` to find it. - You can also refer to the "About" tab of the menu. + If you are on a dev version, please also indicate the git commit hash. + Refer to the "About" tab of the menu or run `minetest --version` on the command line. placeholder: | - Example: + Example: Minetest 5.7.0-dev-ca13c51 (Linux) Using Irrlicht 1.9.0mt9 Using LuaJIT 2.1.0-beta3 @@ -33,59 +32,53 @@ body: render: "true" validations: required: true - - type: input - attributes: - label: Active renderer - description: For graphical and input-related issues. You can find these in the About tab in the mainmenu. - placeholder: "Example: OpenGL 4.6.0" - validations: - required: false - type: input attributes: label: Irrlicht device - description: + description: placeholder: "Example: X11" validations: required: false - type: input attributes: label: Operating system and version - description: It is recommended to upgrade your operating system to see if the problem still exists. + description: It is recommended to upgrade your operating system to see if the problem persists. placeholder: "Example: Ubuntu 22.04" validations: required: true - type: input attributes: label: CPU model - description: Usually found in system settings. - placeholder: "Example: Intel i5-2410M (4) @ 2.900GHz" + description: Usually found in OS/system settings. + placeholder: "Example: Intel Core i5-2410M" validations: required: false - type: markdown attributes: - value: The GPU model and OpenGL version can be omitted if the bug is not a graphical issue. + value: The GPU model and renderer can be omitted if the bug is not a graphical issue. - type: input attributes: label: GPU model - description: Usually found in system settings. - placeholder: "Example: NVIDA GeForce RTX 4090" + description: Usually found in OS/system settings. + placeholder: "Example: NVIDIA GeForce GTX 1660" validations: required: false - type: input attributes: - label: OpenGL version - placeholder: "Example: 4.6" + label: Active renderer + description: You can find this in the "About" tab in the main menu. + placeholder: "Example: OpenGL 4.6.0" validations: required: false - type: textarea - attributes: + attributes: label: Summary description: Describe your problem here. validations: required: true - type: textarea - attributes: + attributes: label: Steps to reproduce - description: Explain how the problem has happened, providing a minimal test (i.e. a code snippet reduced to the bone) where possible. + description: Explain how the problem has happened, providing a minimal test (e.g. a minimized code snippet) where possible. validations: required: true diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 0ef7b9693..8296f5fb5 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -8,6 +8,10 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' - 'android/**' - '.github/workflows/android.yml' pull_request: @@ -16,40 +20,58 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' - 'android/**' - '.github/workflows/android.yml' jobs: build: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 10 - name: Install deps run: | sudo apt-get update - sudo apt-get install -y --no-install-recommends gettext openjdk-11-jdk-headless - - name: Build with Gradle + sudo apt-get install -y --no-install-recommends gettext + - name: Set up JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' + java-version: '17' + - name: Build AAB with Gradle + # We build an AAB as well for uploading to the the Play Store. + run: cd android; ./gradlew bundlerelease + - name: Build APKs with Gradle + # "assemblerelease" is very fast after "bundlerelease". run: cd android; ./gradlew assemblerelease + - name: Save AAB artifact + uses: actions/upload-artifact@v4 + with: + name: Minetest-release.aab + path: android/app/build/outputs/bundle/release/app-release.aab - name: Save armeabi artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Minetest-armeabi-v7a.apk path: android/app/build/outputs/apk/release/app-armeabi-v7a-release-unsigned.apk - name: Save arm64 artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Minetest-arm64-v8a.apk path: android/app/build/outputs/apk/release/app-arm64-v8a-release-unsigned.apk - name: Save x86 artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Minetest-x86.apk path: android/app/build/outputs/apk/release/app-x86-release-unsigned.apk - name: Save x86_64 artifact - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@v4 with: name: Minetest-x86_64.apk path: android/app/build/outputs/apk/release/app-x86_64-release-unsigned.apk diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index f2d4f86f5..000000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,303 +0,0 @@ -name: build - -# build on c/cpp changes or workflow changes -on: - push: - paths: - - 'lib/**.[ch]' - - 'lib/**.cpp' - - 'src/**.[ch]' - - 'src/**.cpp' - - '**/CMakeLists.txt' - - 'cmake/Modules/**' - - 'util/buildbot/**' - - 'util/ci/**' - - '.github/workflows/**.yml' - - 'Dockerfile' - - '.dockerignore' - pull_request: - paths: - - 'lib/**.[ch]' - - 'lib/**.cpp' - - 'src/**.[ch]' - - 'src/**.cpp' - - '**/CMakeLists.txt' - - 'cmake/Modules/**' - - 'util/buildbot/**' - - 'util/ci/**' - - '.github/workflows/**.yml' - - 'Dockerfile' - - '.dockerignore' - -env: - MINETEST_POSTGRESQL_CONNECT_STRING: 'host=localhost user=minetest password=minetest dbname=minetest' - -jobs: - # Older gcc version (should be close to our minimum supported version) - gcc_7: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install deps - run: | - source ./util/ci/common.sh - install_linux_deps g++-7 - - - name: Build - run: | - ./util/ci/build.sh - env: - CC: gcc-7 - CXX: g++-7 - - - name: Test - run: | - ./bin/freeminer --run-unittests - - # Current gcc version - gcc_12: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install deps - run: | - source ./util/ci/common.sh - install_linux_deps g++-12 libluajit-5.1-dev - - - name: Build - run: | - ./util/ci/build.sh - env: - CC: gcc-12 - CXX: g++-12 - - - name: Test - run: | - ./bin/freeminer --run-unittests - - # Older clang version (should be close to our minimum supported version) - clang_7: - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install deps - run: | - source ./util/ci/common.sh - install_linux_deps clang-7 valgrind - - - name: Build - run: | - ./util/ci/build.sh - env: - CC: clang-7 - CXX: clang++-7 - - - name: Unittest - run: | - ./bin/freeminer --run-unittests - - - name: Valgrind - run: | - valgrind --leak-check=full --leak-check-heuristics=all --undef-value-errors=no --error-exitcode=9 ./bin/minetest --run-unittests - - # Current clang version - clang_14: - runs-on: ubuntu-22.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install deps - run: | - source ./util/ci/common.sh - install_linux_deps clang-14 gdb - - - name: Build - run: | - ./util/ci/build.sh - env: - CC: clang-14 - CXX: clang++-14 - - - name: Test - run: | - ./bin/freeminer --run-unittests - - - name: Integration test + devtest - run: | - ./util/test_multiplayer.sh - - # Build with prometheus-cpp (server-only) - clang_9_prometheus: - name: "clang_9 (PROMETHEUS=1)" - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install deps - run: | - source ./util/ci/common.sh - install_linux_deps clang-9 - - - name: Build prometheus-cpp - run: | - ./util/ci/build_prometheus_cpp.sh - - - name: Build - run: | - ./util/ci/build.sh - env: - CC: clang-9 - CXX: clang++-9 - CMAKE_FLAGS: "-DENABLE_PROMETHEUS=1 -DBUILD_CLIENT=0" - - - name: Test - run: | - ./bin/freeminerserver --run-unittests - - docker: - name: "Docker image" - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Build docker image - run: | - docker build . -t minetest:latest - docker run --rm minetest:latest /usr/local/bin/freeminerserver --version - - win32: - if: ${{ false }} - name: "MinGW cross-compiler (32-bit)" - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install compiler - run: | - sudo apt-get update && sudo apt-get install -y gettext - wget http://minetest.kitsunemimi.pw/mingw-w64-i686_11.2.0_ubuntu20.04.tar.xz -O mingw.tar.xz - sudo tar -xaf mingw.tar.xz -C /usr - - - name: Build - run: | - EXISTING_MINETEST_DIR=$PWD ./util/buildbot/buildwin32.sh winbuild - env: - NO_PACKAGE: 1 - - win64: - if: ${{ false }} - name: "MinGW cross-compiler (64-bit)" - runs-on: ubuntu-20.04 - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 - - name: Install compiler - run: | - sudo apt-get update && sudo apt-get install -y gettext - wget http://minetest.kitsunemimi.pw/mingw-w64-x86_64_11.2.0_ubuntu20.04.tar.xz -O mingw.tar.xz - sudo tar -xaf mingw.tar.xz -C /usr - - - name: Build - run: | - EXISTING_MINETEST_DIR=$PWD ./util/buildbot/buildwin64.sh winbuild - env: - NO_PACKAGE: 1 - - msvc: - if: ${{ false }} - name: VS 2019 ${{ matrix.config.arch }}-${{ matrix.type }} - runs-on: windows-2019 - env: - VCPKG_VERSION: 8eb57355a4ffb410a2e94c07b4dca2dffbee8e50 - # 2023.10.19 - vcpkg_packages: zlib zstd curl[winssl] openal-soft libvorbis libogg libjpeg-turbo sqlite3 freetype luajit gmp jsoncpp opengl-registry boost - strategy: - fail-fast: false - matrix: - config: - - { - arch: x86, - generator: "-G'Visual Studio 16 2019' -A Win32", - vcpkg_triplet: x86-windows - } - - { - arch: x64, - generator: "-G'Visual Studio 16 2019' -A x64", - vcpkg_triplet: x64-windows - } - type: [portable] -# type: [portable, installer] -# The installer type is working, but disabled, to save runner jobs. -# Enable it, when working on the installer. - - steps: - - uses: actions/checkout@v3 - with: - submodules: recursive - fetch-depth: 10 -# - name: Checkout IrrlichtMt -# run: | -# $ref = @(Get-Content misc\irrlichtmt_tag.txt) -# git clone https://github.com/minetest/irrlicht lib\irrlichtmt --depth 1 -b $ref[0] - - - name: Restore from cache and run vcpkg - uses: lukka/run-vcpkg@v7 - with: - vcpkgArguments: ${{env.vcpkg_packages}} - vcpkgDirectory: '${{ github.workspace }}\vcpkg' - appendedCacheKey: ${{ matrix.config.vcpkg_triplet }} - vcpkgGitCommitId: ${{ env.VCPKG_VERSION }} - vcpkgTriplet: ${{ matrix.config.vcpkg_triplet }} - - - name: Minetest CMake - run: | - cmake ${{matrix.config.generator}} ` - -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}\vcpkg\scripts\buildsystems\vcpkg.cmake" ` - -DCMAKE_BUILD_TYPE=Release ` - -DENABLE_POSTGRESQL=OFF ` - -DENABLE_LUAJIT=TRUE ` - -DREQUIRE_LUAJIT=TRUE ` - -DRUN_IN_PLACE=${{ contains(matrix.type, 'portable') }} . - - - name: Build Minetest - run: cmake --build . --config Release - - - name: CPack - run: | - If ($env:TYPE -eq "installer") - { - cpack -G WIX -B "$env:GITHUB_WORKSPACE\Package" - } - ElseIf($env:TYPE -eq "portable") - { - cpack -G ZIP -B "$env:GITHUB_WORKSPACE\Package" - } - env: - TYPE: ${{matrix.type}} - - - name: Package Clean - run: rm -r $env:GITHUB_WORKSPACE\Package\_CPack_Packages - - - uses: actions/upload-artifact@v3 - with: - name: msvc-${{ matrix.config.arch }}-${{ matrix.type }} - path: .\Package\ diff --git a/.github/workflows/cpp_lint.yml b/.github/workflows/cpp_lint.yml index 374be5445..01c9cc016 100644 --- a/.github/workflows/cpp_lint.yml +++ b/.github/workflows/cpp_lint.yml @@ -8,6 +8,8 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' - '**/CMakeLists.txt' - 'cmake/Modules/**' - 'util/ci/**' @@ -18,40 +20,28 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' - '**/CMakeLists.txt' - 'cmake/Modules/**' - 'util/ci/**' - '.github/workflows/**.yml' +env: + CLANG_TIDY: clang-tidy-15 + jobs: - -# clang_format: -# runs-on: ubuntu-20.04 -# steps: -# - uses: actions/checkout@v3 -# - name: Install clang-format -# run: | -# sudo apt-get update -# sudo apt-get install -y clang-format-9 -# -# - name: Run clang-format -# run: | -# source ./util/ci/clang-format.sh -# check_format -# env: -# CLANG_FORMAT: clang-format-9 - clang_tidy: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 10 - name: Install deps run: | source ./util/ci/common.sh - install_linux_deps clang-tidy-9 + install_linux_deps $CLANG_TIDY - name: Run clang-tidy run: | diff --git a/.github/workflows/docker_image.yml b/.github/workflows/docker_image.yml new file mode 100644 index 000000000..1a2805e22 --- /dev/null +++ b/.github/workflows/docker_image.yml @@ -0,0 +1,98 @@ +--- +name: docker_image + +# https://docs.github.com/en/actions/publishing-packages/publishing-docker-images +# https://docs.docker.com/build/ci/github-actions/multi-platform +# https://github.com/opencontainers/image-spec/blob/main/annotations.md + +on: + push: + branches: [ "master" ] + # Publish semver tags as releases. + tags: [ "*" ] + pull_request: + # Build docker image on pull requests. (but do not publish) + paths: + - 'lib/**.[ch]' + - 'lib/**.cpp' + - 'src/**.[ch]' + - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' + - 'util/ci/**' + - 'misc/irrlichtmt_tag.txt' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/docker_image.yml' + workflow_dispatch: + inputs: + use_cache: + description: "Use build cache" + required: true + type: boolean + default: true + +env: + REGISTRY: ghcr.io + # github.repository as / + IMAGE_NAME: ${{ github.repository }} + +jobs: + publish: + runs-on: ubuntu-latest + + permissions: + contents: read + packages: write + + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Setup Docker buildx + uses: docker/setup-buildx-action@v3.0.0 + + # Login against the Docker registry except on PR + # https://github.com/docker/login-action + - name: Log into registry ${{ env.REGISTRY }} + if: github.event_name != 'pull_request' + uses: docker/login-action@v3.0.0 + with: + registry: ${{ env.REGISTRY }} + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + # Extract metadata (tags, labels) for Docker + # https://github.com/docker/metadata-action + - name: Extract Docker metadata + id: meta + uses: docker/metadata-action@v5.5.0 + with: + images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }} + labels: | + org.opencontainers.image.title=Minetest + org.opencontainers.image.vendor=Minetest + org.opencontainers.image.licenses=LGPL-2.1-only + + # Build and push Docker image + # https://github.com/docker/build-push-action + # No arm support for now. Require cross-compilation support in Dockerfile to not use QEMU. + - name: Build and push Docker image + uses: docker/build-push-action@v5.1.0 + with: + context: . + platforms: linux/amd64 + push: ${{ github.event_name != 'pull_request' }} + load: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + no-cache: ${{ (github.event_name == 'workflow_dispatch' && !inputs.use_cache) || startsWith(github.ref, 'refs/tags/') }} + + - name: Test Docker Image + run: | + docker run --rm $(cut -d, -f1 <<<"$DOCKER_METADATA_OUTPUT_TAGS") minetestserver --version + shell: bash diff --git a/.github/workflows/linux.yml b/.github/workflows/linux.yml new file mode 100644 index 000000000..d206fe50f --- /dev/null +++ b/.github/workflows/linux.yml @@ -0,0 +1,169 @@ +name: linux + +# build on c/cpp changes or workflow changes +on: + push: + paths: + - 'lib/**.[ch]' + - 'lib/**.cpp' + - 'src/**.[ch]' + - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' + - 'util/ci/**' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/linux.yml' + pull_request: + paths: + - 'lib/**.[ch]' + - 'lib/**.cpp' + - 'src/**.[ch]' + - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' + - 'util/ci/**' + - 'Dockerfile' + - '.dockerignore' + - '.github/workflows/linux.yml' + +env: + MINETEST_POSTGRESQL_CONNECT_STRING: 'host=localhost user=minetest password=minetest dbname=minetest' + +jobs: + # Older gcc version (should be close to our minimum supported version) + gcc_7: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install deps + run: | + source ./util/ci/common.sh + install_linux_deps g++-7 + + - name: Build + run: | + ./util/ci/build.sh + env: + CC: gcc-7 + CXX: g++-7 + + - name: Test + run: | + ./bin/freeminer --run-unittests + + # Current gcc version + gcc_14: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install deps + run: | + source ./util/ci/common.sh + install_linux_deps g++-14 libluajit-5.1-dev + + - name: Build + run: | + ./util/ci/build.sh + env: + CC: gcc-14 + CXX: g++-14 + + - name: Test + run: | + mkdir nowrite + chmod a-w nowrite + cd nowrite + ../bin/freeminer --run-unittests + + # Older clang version (should be close to our minimum supported version) + clang_7: + runs-on: ubuntu-20.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install deps + run: | + source ./util/ci/common.sh + install_linux_deps clang-7 llvm + + - name: Build + run: | + ./util/ci/build.sh + env: + CC: clang-7 + CXX: clang++-7 + CMAKE_FLAGS: '-DCMAKE_C_FLAGS="-fsanitize=address" -DCMAKE_CXX_FLAGS="-fsanitize=address"' + + - name: Unittest + run: | + ./bin/freeminer --run-unittests + + # Current clang version + clang_18: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install deps + run: | + source ./util/ci/common.sh + install_linux_deps clang-18 lldb + + - name: Build + run: | + ./util/ci/build.sh + env: + CC: clang-18 + CXX: clang++-18 + + - name: Test + run: | + ./bin/freeminer --run-unittests + + - name: Integration test + devtest + run: | + ./util/test_multiplayer.sh + + # Build with prometheus-cpp (server-only) + clang_11_prometheus: + name: "clang_11 (PROMETHEUS=1)" + runs-on: ubuntu-22.04 + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install deps + run: | + source ./util/ci/common.sh + install_linux_deps clang-11 + + - name: Build prometheus-cpp + run: ./util/ci/build_prometheus_cpp.sh + + - name: Build + run: | + ./util/ci/build.sh + env: + CC: clang-11 + CXX: clang++-11 + CMAKE_FLAGS: "-DENABLE_PROMETHEUS=1 -DBUILD_CLIENT=0 -DENABLE_CURSES=0" + + - name: Test + run: | + ./bin/freeminerserver --run-unittests diff --git a/.github/workflows/lua.yml b/.github/workflows/lua.yml index 39a0a7d36..c754de701 100644 --- a/.github/workflows/lua.yml +++ b/.github/workflows/lua.yml @@ -14,27 +14,27 @@ on: - '.github/workflows/**.yml' jobs: - # Note that the integration tests are also run build.yml, but only when C++ code is changed. + # Note that the integration tests are also run in build.yml, but only when C++ code is changed. integration_tests: name: "Compile and run multiplayer tests" - runs-on: ubuntu-20.04 + runs-on: ubuntu-24.04 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 10 - name: Install deps run: | source ./util/ci/common.sh - install_linux_deps clang-10 gdb libluajit-5.1-dev + install_linux_deps clang gdb libluajit-5.1-dev - name: Build run: | ./util/ci/build.sh env: - CC: clang-10 - CXX: clang++-10 - CMAKE_FLAGS: "-DENABLE_GETTEXT=0 -DBUILD_SERVER=0" + CC: clang + CXX: clang++ + CMAKE_FLAGS: "-DENABLE_GETTEXT=0 -DBUILD_SERVER=0 -DBUILD_UNITTESTS=0" - name: Integration test + devtest run: | @@ -42,25 +42,21 @@ jobs: luacheck: name: "Builtin Luacheck and Unit Tests" - runs-on: ubuntu-20.04 + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 10 - - uses: leafo/gh-actions-lua@v9 + - uses: leafo/gh-actions-lua@v10 with: luaVersion: "5.1.5" - - uses: leafo/gh-actions-luarocks@v4 + - uses: leafo/gh-actions-luarocks@v4.3.0 - name: Install LuaJIT - run: | - cd $HOME - git clone https://github.com/LuaJIT/LuaJIT/ - cd LuaJIT - make -j$(nproc) + run: ./util/ci/build_luajit.sh - name: Install luarocks tools run: | diff --git a/.github/workflows/lua_api_deploy.yml b/.github/workflows/lua_api_deploy.yml index 574e3ba3e..e42335a78 100644 --- a/.github/workflows/lua_api_deploy.yml +++ b/.github/workflows/lua_api_deploy.yml @@ -16,13 +16,13 @@ on: jobs: build: - runs-on: ubuntu-22.04 - + if: github.repository == 'minetest/minetest' + runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - name: Set up Python - uses: actions/setup-python@v4 + uses: actions/setup-python@v5 with: python-version: 3.11 @@ -36,13 +36,13 @@ jobs: ./build.sh - name: Setup Pages - uses: actions/configure-pages@v3 + uses: actions/configure-pages@v4 - name: Upload artifact - uses: actions/upload-pages-artifact@v2 + uses: actions/upload-pages-artifact@v3 with: path: 'public/' - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v2 + uses: actions/deploy-pages@v4 diff --git a/.github/workflows/macos.yml b/.github/workflows/macos.yml index 8044961af..bfe12fa54 100644 --- a/.github/workflows/macos.yml +++ b/.github/workflows/macos.yml @@ -8,6 +8,9 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - 'irr/**.mm' # Objective-C(++) - '**/CMakeLists.txt' - 'cmake/Modules/**' - '.github/workflows/macos.yml' @@ -17,15 +20,19 @@ on: - 'lib/**.cpp' - 'src/**.[ch]' - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - 'irr/**.mm' # Objective-C(++) - '**/CMakeLists.txt' - 'cmake/Modules/**' - '.github/workflows/macos.yml' jobs: build: - runs-on: macos-latest + # use macOS 13 since it's the last one that still runs on x86 + runs-on: macos-13 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: submodules: recursive fetch-depth: 10 @@ -36,7 +43,6 @@ jobs: - name: Build run: | - # git clone https://github.com/minetest/irrlicht lib/irrlichtmt --depth 1 -b $(cat misc/irrlichtmt_tag.txt) mkdir build cd build cmake .. \ @@ -57,9 +63,11 @@ jobs: - name: CPack run: | cd build + rm -rf macos + cmake .. -DINSTALL_DEVTEST=FALSE cpack -G ZIP -B macos - - uses: actions/upload-artifact@v3 + - uses: actions/upload-artifact@v4 with: name: minetest-macos path: ./build/macos/*.zip diff --git a/.github/workflows/windows.yml b/.github/workflows/windows.yml new file mode 100644 index 000000000..54524270a --- /dev/null +++ b/.github/workflows/windows.yml @@ -0,0 +1,150 @@ +name: windows + +# build on c/cpp changes or workflow changes +on: + push: + paths: + - 'lib/**.[ch]' + - 'lib/**.cpp' + - 'src/**.[ch]' + - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' + - 'util/buildbot/**' + - 'misc/*.manifest' + - '.github/workflows/windows.yml' + pull_request: + paths: + - 'lib/**.[ch]' + - 'lib/**.cpp' + - 'src/**.[ch]' + - 'src/**.cpp' + - 'irr/**.[ch]' + - 'irr/**.cpp' + - '**/CMakeLists.txt' + - 'cmake/Modules/**' + - 'util/buildbot/**' + - 'misc/*.manifest' + - '.github/workflows/windows.yml' + +jobs: + mingw: + name: "MinGW cross-compiler (${{ matrix.bits }}-bit)" + runs-on: ubuntu-22.04 + strategy: + fail-fast: false + matrix: + bits: [32, 64] + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + - name: Install compiler + run: | + sudo dpkg --add-architecture i386 + sudo apt-get update + sudo apt-get install -y --no-install-recommends gettext wine wine${{ matrix.bits }} + sudo ./util/buildbot/download_toolchain.sh /usr + + - name: Build + run: | + EXISTING_MINETEST_DIR=$PWD \ + ./util/buildbot/buildwin${{ matrix.bits }}.sh B + + # Check that the resulting binary can run (DLLs etc.) + - name: Runtime test + run: | + dest=$(mktemp -d) + unzip -q -d "$dest" B/build/*.zip + cd "$dest"/minetest-*-win* + wine bin/minetest.exe --version + + - uses: actions/upload-artifact@v4 + with: + name: "mingw${{ matrix.bits }}" + path: B/build/*.zip + if-no-files-found: error + + msvc: + name: VS 2019 ${{ matrix.config.arch }}-${{ matrix.type }} + runs-on: windows-2019 + env: + VCPKG_VERSION: 01f602195983451bc83e72f4214af2cbc495aa94 + # 2024.05.24 + vcpkg_packages: zlib zstd curl[winssl] openal-soft libvorbis libogg libjpeg-turbo sqlite3 freetype luajit gmp jsoncpp opengl-registry boost + strategy: + fail-fast: false + matrix: + config: + - { + arch: x86, + generator: "-G'Visual Studio 16 2019' -A Win32", + vcpkg_triplet: x86-windows + } + - { + arch: x64, + generator: "-G'Visual Studio 16 2019' -A x64", + vcpkg_triplet: x64-windows + } + type: [portable] +# type: [portable, installer] +# The installer type is working, but disabled, to save runner jobs. +# Enable it, when working on the installer. + + steps: + - uses: actions/checkout@v4 + with: + submodules: recursive + fetch-depth: 10 + + - name: Restore from cache and run vcpkg + uses: lukka/run-vcpkg@v7 + with: + vcpkgArguments: ${{env.vcpkg_packages}} + vcpkgDirectory: '${{ github.workspace }}\vcpkg' + appendedCacheKey: ${{ matrix.config.vcpkg_triplet }} + vcpkgGitCommitId: ${{ env.VCPKG_VERSION }} + vcpkgTriplet: ${{ matrix.config.vcpkg_triplet }} + + - name: Minetest CMake + run: | + cmake ${{matrix.config.generator}} ` + -DCMAKE_TOOLCHAIN_FILE="${{ github.workspace }}\vcpkg\scripts\buildsystems\vcpkg.cmake" ` + -DCMAKE_BUILD_TYPE=Release ` + -DENABLE_POSTGRESQL=OFF ` + -DENABLE_LUAJIT=TRUE ` + -DREQUIRE_LUAJIT=TRUE ` + -DRUN_IN_PLACE=${{ contains(matrix.type, 'portable') }} . + + - name: Build Minetest + run: cmake --build . --config Release + + - name: Unittests + # need this workaround for stdout to work + run: | + $proc = start .\bin\Release\minetest.exe --run-unittests -NoNewWindow -Wait -PassThru + exit $proc.ExitCode + continue-on-error: true # FIXME!! + + - name: CPack + run: | + If ($env:TYPE -eq "installer") + { + cpack -G WIX -B "$env:GITHUB_WORKSPACE\Package" + } + ElseIf($env:TYPE -eq "portable") + { + cpack -G ZIP -B "$env:GITHUB_WORKSPACE\Package" + } + rm -r $env:GITHUB_WORKSPACE\Package\_CPack_Packages + env: + TYPE: ${{matrix.type}} + + - uses: actions/upload-artifact@v4 + with: + name: msvc-${{ matrix.config.arch }}-${{ matrix.type }} + path: .\Package\ + if-no-files-found: error diff --git a/.gitignore b/.gitignore index b5c71870f..0d2167013 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ ## Editors and development environments *~ +.cmake +CMakeUserPresets.json +Testing/* *.swp *.bak* *.orig @@ -26,7 +29,8 @@ gtags.files # Codelite *.project # Visual Studio Code & plugins -.vscode/ +.vscode/* +!.vscode/extensions.json build/.cmake/ # Fleet .fleet @@ -64,6 +68,7 @@ AppDir /clientmods/* !/clientmods/preview/ /client/mod_storage/ +/mod_data ## Configuration/log files minetest.conf @@ -92,11 +97,8 @@ cmake_install.cmake CMakeCache.txt CPackConfig.cmake CPackSourceConfig.cmake -src/test_config.h src/cmake_config.h src/cmake_config_githash.h -src/unittest/test_world/world.mt -games/devtest/mods/testnodes/textures/testnodes_generated_*.png /locale/ .directory *.cbp @@ -109,7 +111,10 @@ games/devtest/mods/testnodes/textures/testnodes_generated_*.png *.iml test_config.h cmake-build-debug/ +cmake-build-minsizerel/ cmake-build-release/ +cmake-build-relwithdebinfo/ +cmake-build-default/ cmake_config.h cmake_config_githash.h CMakeDoxy* @@ -164,7 +169,7 @@ build_tools/android_fm/path.cfg timestamp -# Optional user provided library folder +# Old irrlichtmt. Still ignored to make bisecting easier. lib/irrlichtmt # Generated mod storage database diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 10568e2e1..d4c5525aa 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -3,128 +3,14 @@ # https://gitlab.com/minetest/minetest # Pipelines URL: https://gitlab.com/minetest/minetest/pipelines -stages: - - build - - package - - deploy - -variables: - CONTAINER_IMAGE: registry.gitlab.com/$CI_PROJECT_PATH - -.build_template: - stage: build - before_script: - - apt-get update - - DEBIAN_FRONTEND=noninteractive apt-get -y install build-essential gettext git cmake libpng-dev libjpeg-dev libxi-dev libgl1-mesa-dev libsqlite3-dev libleveldb-dev libogg-dev libvorbis-dev libopenal-dev libcurl4-gnutls-dev libfreetype6-dev zlib1g-dev libgmp-dev libjsoncpp-dev libzstd-dev libluajit-5.1-dev - script: - - git clone https://github.com/minetest/irrlicht lib/irrlichtmt --depth 1 -b $(cat misc/irrlichtmt_tag.txt) - - mkdir build && cd build - - cmake -DCMAKE_INSTALL_PREFIX=../artifact/minetest/usr/ -DRUN_IN_PLACE=FALSE -DENABLE_GETTEXT=TRUE .. - - make -j $(($(nproc) + 1)) - - make install - artifacts: - when: on_success - expire_in: 1h - paths: - - artifact/* - -## -## Ubuntu (prerequisite for AppImage build) -## - -build:ubuntu-20.04: - extends: .build_template - image: ubuntu:focal - -## -## MinGW for Windows -## - -.generic_win_template: - image: ubuntu:focal - before_script: - - apt-get update - - DEBIAN_FRONTEND=noninteractive apt-get install -y wget xz-utils unzip git cmake gettext - - wget -nv http://minetest.kitsunemimi.pw/mingw-w64-${WIN_ARCH}_11.2.0_ubuntu20.04.tar.xz -O mingw.tar.xz - - tar -xaf mingw.tar.xz -C /usr - -.build_win_template: - extends: .generic_win_template - stage: build - artifacts: - expire_in: 90 day - paths: - - minetest-*-win*/* - -build:win32: - extends: .build_win_template - script: - - EXISTING_MINETEST_DIR=$PWD ./util/buildbot/buildwin32.sh build - - unzip -q build/build/*.zip - variables: - WIN_ARCH: "i686" - -build:win64: - extends: .build_win_template - script: - - EXISTING_MINETEST_DIR=$PWD ./util/buildbot/buildwin64.sh build - - unzip -q build/build/*.zip - variables: - WIN_ARCH: "x86_64" - -## -## Docker -## - -package:docker: - stage: package - image: docker:stable - services: - - docker:dind - before_script: - - docker login -u gitlab-ci-token -p $CI_JOB_TOKEN registry.gitlab.com - script: - - ./util/ci/docker.sh - -## -## Gitlab Pages (Lua API documentation) -## pages: stage: deploy image: python:3.8 - before_script: - - pip install -U -r doc/mkdocs/requirements.txt script: - - cd doc/mkdocs && ./build.sh + - ./misc/make_redirects.sh artifacts: paths: - public only: - master - -## -## AppImage -## - -package:appimage-client: - stage: package - image: appimagecrafters/appimage-builder - needs: - - build:ubuntu-20.04 - before_script: - - apt-get update - - apt-get install -y git - # Collect files - - mkdir AppDir - - cp -a artifact/minetest/usr/ AppDir/usr/ - - cp -a clientmods AppDir/usr/share/minetest - # Remove PrefersNonDefaultGPU property due to validation errors - - sed -i '/PrefersNonDefaultGPU/d' AppDir/usr/share/applications/net.minetest.minetest.desktop - script: - - export VERSION=$CI_COMMIT_REF_NAME-$CI_COMMIT_SHORT_SHA - - appimage-builder --skip-test --recipe misc/AppImageBuilder.yml - artifacts: - expire_in: 90 day - paths: - - ./*.AppImage diff --git a/.luacheckrc b/.luacheckrc index 54ece656a..fcc04cab3 100644 --- a/.luacheckrc +++ b/.luacheckrc @@ -17,6 +17,7 @@ read_globals = { "VoxelArea", "profiler", "Settings", + "PerlinNoise", "PerlinNoiseMap", string = {fields = {"split", "trim"}}, table = {fields = {"copy", "getn", "indexof", "insert_all"}}, @@ -70,7 +71,6 @@ files["builtin/mainmenu"] = { read_globals = { "PLATFORM", - "TOUCHSCREEN_GUI", }, } @@ -81,9 +81,3 @@ files["builtin/common/tests"] = { "assert", }, } - -files["builtin/fstk"] = { - read_globals = { - "TOUCHSCREEN_GUI", - }, -} diff --git a/.vscode/extensions.json b/.vscode/extensions.json new file mode 100644 index 000000000..fd9b2c09b --- /dev/null +++ b/.vscode/extensions.json @@ -0,0 +1,5 @@ +{ + "recommendations": [ + "ms-vscode.cpptools-extension-pack" + ] +} \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt index b47c0f092..a34dc99e7 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -1,11 +1,4 @@ -cmake_minimum_required(VERSION 3.5) - -# Set policies up to 3.9 since we want to enable the IPO option -if(${CMAKE_VERSION} VERSION_LESS 3.9) - cmake_policy(VERSION ${CMAKE_MAJOR_VERSION}.${CMAKE_MINOR_VERSION}) -else() - cmake_policy(VERSION 3.9) -endif() +cmake_minimum_required(VERSION 3.12) # This can be read from ${PROJECT_NAME} after project() is called project(freeminer) @@ -19,7 +12,7 @@ set(CLANG_MINIMUM_VERSION "7.0.1") # You should not need to edit these manually, use util/bump_version.sh set(VERSION_MAJOR 5) -set(VERSION_MINOR 8) +set(VERSION_MINOR 9) set(VERSION_PATCH 0) set(VERSION_EXTRA "" CACHE STRING "Stuff to append to version string") @@ -45,6 +38,27 @@ set(BUILD_UNITTESTS TRUE CACHE BOOL "Build unittests") set(BUILD_BENCHMARKS FALSE CACHE BOOL "Build benchmarks") set(BUILD_DOCUMENTATION TRUE CACHE BOOL "Build documentation") +if(NOT DEVELOPMENT_BUILD) +set(DEFAULT_ENABLE_LTO TRUE) +endif() +# by default don't enable on Debug builds to get faster builds +if(CMAKE_BUILD_TYPE STREQUAL "Debug") + set(DEFAULT_ENABLE_LTO FALSE) +endif() +#### LTO testing list #### +# - Linux: seems to work always +# - win32/msvc: works +# - win32/gcc: fails to link +# - win32/clang: works +# - macOS on x86: seems to be fine +# - macOS on ARM: crashes, see +# Note: since CMake has no easy architecture detection disabling for Mac entirely +#### #### +if((WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") OR APPLE) + set(DEFAULT_ENABLE_LTO FALSE) +endif() +set(ENABLE_LTO ${DEFAULT_ENABLE_LTO} CACHE BOOL "Use Link Time Optimization") + set(DEFAULT_RUN_IN_PLACE FALSE) set(DEFAULT_RUN_IN_PLACE TRUE) if(WIN32) @@ -160,29 +174,11 @@ if(ANDROID) endif() -set(IRRLICHTMT_BUILD_DIR "" CACHE PATH "Path to IrrlichtMt build directory.") if(NOT NO_BUILD) -if(ANDROID) - # currently manually provided -elseif(NOT "${IRRLICHTMT_BUILD_DIR}" STREQUAL "") - find_package(IrrlichtMt QUIET - PATHS "${IRRLICHTMT_BUILD_DIR}" - NO_DEFAULT_PATH - ) - - if(NOT TARGET IrrlichtMt::IrrlichtMt) - # find_package() searches certain subdirectories. ${PATH}/cmake is not - # the only one, but it is the one where IrrlichtMt is supposed to export - # IrrlichtMtConfig.cmake - message(FATAL_ERROR "Could not find IrrlichtMtConfig.cmake in ${IRRLICHTMT_BUILD_DIR}/cmake.") - endif() -elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/lib/irrlichtmt") - message(STATUS "Using user-provided IrrlichtMt at subdirectory 'lib/irrlichtmt'") +if(TRUE) + message(STATUS "Using imported IrrlichtMt at subdirectory 'irr'") if(BUILD_CLIENT) - # tell IrrlichtMt to create a static library - #set(BUILD_SHARED_LIBS OFF CACHE BOOL "Build shared library" FORCE) - add_subdirectory(lib/irrlichtmt EXCLUDE_FROM_ALL) - #unset(BUILD_SHARED_LIBS CACHE) + add_subdirectory(irr EXCLUDE_FROM_ALL) if(NOT TARGET IrrlichtMt) message(FATAL_ERROR "IrrlichtMt project is missing a CMake target?!") @@ -190,47 +186,22 @@ elseif(EXISTS "${CMAKE_CURRENT_SOURCE_DIR}/lib/irrlichtmt") else() add_library(IrrlichtMt::IrrlichtMt INTERFACE IMPORTED) set_target_properties(IrrlichtMt::IrrlichtMt PROPERTIES - INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/lib/irrlichtmt/include") - endif() -else() - find_package(IrrlichtMt QUIET) - if(NOT TARGET IrrlichtMt::IrrlichtMt) - string(CONCAT explanation_msg - "The Minetest team has forked Irrlicht to make their own customizations. " - "It can be found here: https://github.com/minetest/irrlicht\n" - "For example use: git clone --depth=1 https://github.com/minetest/irrlicht lib/irrlichtmt\n") - if(BUILD_CLIENT) - message(FATAL_ERROR "IrrlichtMt is required to build the client, but it was not found.\n${explanation_msg}") - endif() - - include(MinetestFindIrrlichtHeaders) - if(NOT IRRLICHT_INCLUDE_DIR) - message(FATAL_ERROR "IrrlichtMt headers are required to build the server, but none found.\n${explanation_msg}") - endif() - message(STATUS "Found IrrlichtMt headers: ${IRRLICHT_INCLUDE_DIR}") - add_library(IrrlichtMt::IrrlichtMt INTERFACE IMPORTED) - # Note that we can't use target_include_directories() since that doesn't work for IMPORTED targets before CMake 3.11 - set_target_properties(IrrlichtMt::IrrlichtMt PROPERTIES - INTERFACE_INCLUDE_DIRECTORIES "${IRRLICHT_INCLUDE_DIR}") + INTERFACE_INCLUDE_DIRECTORIES "${CMAKE_CURRENT_SOURCE_DIR}/irr/include") endif() endif() -if(ANDROID) - # skipped for now -elseif(BUILD_CLIENT AND TARGET IrrlichtMt::IrrlichtMt) - # retrieve version somehow - if(NOT IrrlichtMt_VERSION) - get_target_property(IrrlichtMt_VERSION IrrlichtMt VERSION) - endif() - message(STATUS "Found IrrlichtMt ${IrrlichtMt_VERSION}") - - set(TARGET_VER_S 1.9.0mt13) - string(REPLACE "mt" "." TARGET_VER ${TARGET_VER_S}) - if(IrrlichtMt_VERSION VERSION_LESS ${TARGET_VER}) - message(FATAL_ERROR "At least IrrlichtMt ${TARGET_VER_S} is required to build") - elseif(NOT DEVELOPMENT_BUILD AND IrrlichtMt_VERSION VERSION_GREATER ${TARGET_VER}) - message(FATAL_ERROR "IrrlichtMt ${TARGET_VER_S} is required to build") +if (ENABLE_LTO OR CMAKE_INTERPROCEDURAL_OPTIMIZATION) + include(CheckIPOSupported) + check_ipo_supported(RESULT lto_supported OUTPUT lto_output) + if(lto_supported) + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION TRUE) + message(STATUS "LTO/IPO is enabled") + else() + set(CMAKE_INTERPROCEDURAL_OPTIMIZATION FALSE) + message(STATUS "LTO/IPO was requested but is not supported by the compiler: ${lto_output}") endif() +else() + message(STATUS "LTO/IPO is not enabled") endif() endif() @@ -270,7 +241,7 @@ elseif(UNIX) # Linux, BSD etc set(EXAMPLE_CONF_DIR ".") set(MANDIR "unix/man") set(XDG_APPS_DIR "unix/applications") - set(APPDATADIR "unix/metainfo") + set(METAINFODIR "unix/metainfo") set(ICONDIR "unix/icons") set(LOCALEDIR "locale") else() @@ -281,7 +252,7 @@ elseif(UNIX) # Linux, BSD etc set(MANDIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_MANDIR}") set(EXAMPLE_CONF_DIR ${DOCDIR}) set(XDG_APPS_DIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/applications") - set(APPDATADIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/metainfo") + set(METAINFODIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/metainfo") set(ICONDIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_DATADIR}/icons") set(LOCALEDIR "${CMAKE_INSTALL_PREFIX}/${CMAKE_INSTALL_LOCALEDIR}") endif() @@ -376,7 +347,7 @@ install(FILES "freeminer.conf.example" DESTINATION "${EXAMPLE_CONF_DIR}") if(UNIX AND NOT APPLE) install(FILES "doc/freeminer.6" "doc/freeminerserver.6" DESTINATION "${MANDIR}/man6") install(FILES "misc/org.freeminer.freeminer.desktop" DESTINATION "${XDG_APPS_DIR}") - install(FILES "misc/org.freeminer.freeminer.appdata.xml" DESTINATION "${APPDATADIR}") + install(FILES "misc/org.freeminer.freeminer.metainfo.xml" DESTINATION "${METAINFODIR}") install(FILES "misc/freeminer.svg" DESTINATION "${ICONDIR}/hicolor/scalable/apps") install(FILES "misc/freeminer-xorg-icon-128.png" DESTINATION "${ICONDIR}/hicolor/128x128/apps" @@ -395,8 +366,9 @@ find_package(Lua REQUIRED) if(NOT USE_LUAJIT) add_subdirectory(lib/bitop) endif() +add_subdirectory(lib/sha256) -if(BUILD_BENCHMARKS) +if(BUILD_UNITTESTS OR BUILD_BENCHMARKS) add_subdirectory(lib/catch2) endif() diff --git a/CMakePresets.json b/CMakePresets.json new file mode 100644 index 000000000..d1e6a4037 --- /dev/null +++ b/CMakePresets.json @@ -0,0 +1,41 @@ +{ + "version": 3, + "cmakeMinimumRequired": { + "major": 3, + "minor": 12 + }, + "configurePresets": [ + { + "name": "Debug", + "displayName": "Debug", + "description": "Debug preset with debug symbols and no optimizations", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Debug" + } + }, + { + "name": "Release", + "displayName": "Release", + "description": "Release preset with optimizations and no debug symbols", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "Release" + } + }, + { + "name": "RelWithDebInfo", + "displayName": "RelWithDebInfo", + "description": "Release with debug symbols", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "RelWithDebInfo" + } + }, + { + "name": "MinSizeRel", + "displayName": "MinSizeRel", + "description": "Release with minimal code size", + "cacheVariables": { + "CMAKE_BUILD_TYPE": "MinSizeRel" + } + } + ] +} diff --git a/Dockerfile b/Dockerfile index 3205932e6..d3ec1b422 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,6 @@ -ARG DOCKER_IMAGE=alpine:3.16 +ARG DOCKER_IMAGE=alpine:3.19 FROM $DOCKER_IMAGE AS dev -ENV IRRLICHT_VERSION master -ENV SPATIALINDEX_VERSION 1.9.3 ENV LUAJIT_VERSION v2.1 RUN apk add --no-cache git build-base cmake curl-dev zlib-dev zstd-dev \ @@ -10,7 +8,7 @@ RUN apk add --no-cache git build-base cmake curl-dev zlib-dev zstd-dev \ gmp-dev jsoncpp-dev ninja ca-certificates WORKDIR /usr/src/ -RUN git clone --recursive https://github.com/jupp0r/prometheus-cpp/ && \ +RUN git clone --recursive https://github.com/jupp0r/prometheus-cpp && \ cd prometheus-cpp && \ cmake -B build \ -DCMAKE_INSTALL_PREFIX=/usr/local \ @@ -20,7 +18,7 @@ RUN git clone --recursive https://github.com/jupp0r/prometheus-cpp/ && \ cmake --build build && \ cmake --install build && \ cd /usr/src/ && \ - git clone --recursive https://github.com/libspatialindex/libspatialindex -b ${SPATIALINDEX_VERSION} && \ + git clone --recursive https://github.com/libspatialindex/libspatialindex && \ cd libspatialindex && \ cmake -B build \ -DCMAKE_INSTALL_PREFIX=/usr/local && \ @@ -29,10 +27,8 @@ RUN git clone --recursive https://github.com/jupp0r/prometheus-cpp/ && \ cd /usr/src/ && \ git clone --recursive https://luajit.org/git/luajit.git -b ${LUAJIT_VERSION} && \ cd luajit && \ - make && make install && \ - cd /usr/src/ && \ - git clone --depth=1 https://github.com/minetest/irrlicht/ -b ${IRRLICHT_VERSION} && \ - cp -r irrlicht/include /usr/include/irrlichtmt + make amalg && make install && \ + cd /usr/src/ COPY mods /usr/src/minetest/mods @@ -50,6 +46,7 @@ COPY lib /usr/src/minetest/lib COPY misc /usr/src/minetest/misc COPY po /usr/src/minetest/po COPY src /usr/src/minetest/src +COPY irr /usr/src/minetest/irr COPY textures /usr/src/minetest/textures COPY games/default /usr/src/minetest/games/default @@ -60,14 +57,13 @@ RUN cmake -B build \ -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SERVER=TRUE \ -DENABLE_PROMETHEUS=TRUE \ - -DBUILD_UNITTESTS=FALSE \ + -DBUILD_UNITTESTS=FALSE -DBUILD_BENCHMARKS=FALSE \ -DBUILD_CLIENT=FALSE \ -DRUN_IN_PLACE=0 \ -GNinja && \ cmake --build build && \ cmake --install build -ARG DOCKER_IMAGE=alpine:3.16 FROM $DOCKER_IMAGE AS runtime RUN apk add --no-cache curl gmp libstdc++ libgcc libpq jsoncpp zstd-libs \ diff --git a/README.md b/README.md index 76489dc4f..54d2c194f 100644 --- a/README.md +++ b/README.md @@ -175,11 +175,11 @@ Table of Contents Further documentation ---------------------- -- Website: https://minetest.net/ +- Website: https://www.minetest.net/ - Wiki: https://wiki.minetest.net/ -- Developer wiki: https://dev.minetest.net/ - Forum: https://forum.minetest.net/ - GitHub: https://github.com/minetest/minetest/ +- [Developer documentation](doc/developing/) - [doc/](doc/) directory of source distribution Default controls @@ -277,30 +277,7 @@ Docker ------ - [Developing minetestserver with Docker](doc/developing/docker.md) - -We provide Minetest server Docker images using the GitLab mirror registry. - -Images are built on each commit and available using the following tag scheme: - -* `registry.gitlab.com/minetest/minetest/server:latest` (latest build) -* `registry.gitlab.com/minetest/minetest/server:` (current branch or current tag) -* `registry.gitlab.com/minetest/minetest/server:` (current commit id) - -If you want to test it on a Docker server you can easily run: - - sudo docker run registry.gitlab.com/minetest/minetest/server: - -If you want to use it in a production environment you should use volumes bound to the Docker host -to persist data and modify the configuration: - - sudo docker create -v /home/minetest/data/:/var/lib/minetest/ -v /home/minetest/conf/:/etc/minetest/ registry.gitlab.com/minetest/minetest/server:master - -Data will be written to `/home/minetest/data` on the host, and configuration will be read from `/home/minetest/conf/minetest.conf`. - -**Note:** If you don't understand the previous commands please read the official Docker documentation before use. - -You can also host your Minetest server inside a Kubernetes cluster. See our example implementation in [`misc/kubernetes.yml`](misc/kubernetes.yml). - +- [Running a server with Docker](doc/docker_server.md) Version scheme -------------- diff --git a/android/SDL_Java_RMB_fix.patch b/android/SDL_Java_RMB_fix.patch new file mode 100644 index 000000000..cc0eb4772 --- /dev/null +++ b/android/SDL_Java_RMB_fix.patch @@ -0,0 +1,18 @@ +diff --git a/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/android/app/src/main/java/org/libsdl/app/SDLActivity.java +index fd5a056e3..83e3cf657 100644 +--- a/android/app/src/main/java/org/libsdl/app/SDLActivity.java ++++ b/android/app/src/main/java/org/libsdl/app/SDLActivity.java +@@ -1345,7 +1345,12 @@ public class SDLActivity extends Activity implements View.OnSystemUiVisibilityCh + } + } + +- if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { ++ if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE || ++ /* ++ * CUSTOM ADDITION FOR MINETEST ++ * should be upstreamed ++ */ ++ (source & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { diff --git a/android/app/build.gradle b/android/app/build.gradle index 6bacc3d55..fe6c4ab0d 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -1,17 +1,20 @@ apply plugin: 'com.android.application' android { - compileSdkVersion 33 - buildToolsVersion '33.0.2' ndkVersion "$ndk_version" defaultConfig { applicationId 'net.minetest.minetest' minSdkVersion 21 - targetSdkVersion 33 + compileSdk 34 + targetSdkVersion 34 versionName "${versionMajor}.${versionMinor}.${versionPatch}" versionCode project.versionCode } + buildFeatures { + buildConfig true + } + // load properties Properties props = new Properties() def propfile = file('../local.properties') @@ -49,6 +52,7 @@ android { sourceCompatibility JavaVersion.VERSION_1_8 targetCompatibility JavaVersion.VERSION_1_8 } + namespace 'net.minetest.minetest' } task prepareAssets() { @@ -75,7 +79,7 @@ task prepareAssets() { from "${projRoot}/client/shaders" into "${assetsFolder}/client/shaders" } copy { - from "../native/deps/armeabi-v7a/Irrlicht/Shaders" into "${assetsFolder}/client/shaders/Irrlicht" + from "${projRoot}/irr/media/Shaders" into "${assetsFolder}/client/shaders/Irrlicht" } copy { from "${projRoot}/fonts" include "*.ttf" into "${assetsFolder}/fonts" @@ -108,6 +112,10 @@ task prepareAssets() { preBuild.dependsOn zipAssets prepareAssets.dependsOn ':native:getDeps' +clean { + delete new File("src/main/assets", "Minetest.zip") +} + // Map for the version code that gives each ABI a value. import com.android.build.OutputFile diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index b629610d8..9197b9131 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,7 @@ + android:installLocation="auto"> diff --git a/android/app/src/main/java/net/minetest/minetest/GameActivity.java b/android/app/src/main/java/net/minetest/minetest/GameActivity.java index caac0637d..b0e301c53 100644 --- a/android/app/src/main/java/net/minetest/minetest/GameActivity.java +++ b/android/app/src/main/java/net/minetest/minetest/GameActivity.java @@ -20,8 +20,10 @@ with this program; if not, write to the Free Software Foundation, Inc., package net.minetest.minetest; -import android.app.NativeActivity; +import org.libsdl.app.SDLActivity; + import android.content.Intent; +import android.content.ActivityNotFoundException; import android.net.Uri; import android.os.Bundle; import android.text.InputType; @@ -32,6 +34,8 @@ import android.view.WindowManager; import android.view.inputmethod.InputMethodManager; import android.widget.Button; import android.widget.LinearLayout; +import android.widget.Toast; +import android.content.res.Configuration; import androidx.annotation.Keep; import androidx.appcompat.app.AlertDialog; @@ -45,51 +49,59 @@ import java.util.Objects; // This annotation prevents the minifier/Proguard from mangling them. @Keep @SuppressWarnings("unused") -public class GameActivity extends NativeActivity { - static { - System.loadLibrary("c++_shared"); - System.loadLibrary("minetest"); +public class GameActivity extends SDLActivity { + @Override + protected String getMainSharedObject() { + return getContext().getApplicationInfo().nativeLibraryDir + "/libminetest.so"; } - private int messageReturnCode = -1; + @Override + protected String getMainFunction() { + return "SDL_Main"; + } + + @Override + protected String[] getLibraries() { + return new String[] { + "minetest" + }; + } + + // Prevent SDL from changing orientation settings since we already set the + // correct orientation in our AndroidManifest.xml + @Override + public void setOrientationBis(int w, int h, boolean resizable, String hint) {} + + enum DialogType { TEXT_INPUT, SELECTION_INPUT } + enum DialogState { DIALOG_SHOWN, DIALOG_INPUTTED, DIALOG_CANCELED } + + private DialogType lastDialogType = DialogType.TEXT_INPUT; + private DialogState inputDialogState = DialogState.DIALOG_CANCELED; private String messageReturnValue = ""; + private int selectionReturnValue = 0; + + private native void saveSettings(); @Override - public void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - getWindow().addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + protected void onStop() { + super.onStop(); + // Avoid losing setting changes in case the app is onDestroy()ed later. + // Saving stuff in onStop() is recommended in the Android activity + // lifecycle documentation. + saveSettings(); } - private void makeFullScreen() { - this.getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | - View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY); + public void showTextInputDialog(String hint, String current, int editType) { + runOnUiThread(() -> showTextInputDialogUI(hint, current, editType)); } - @Override - public void onWindowFocusChanged(boolean hasFocus) { - super.onWindowFocusChanged(hasFocus); - if (hasFocus) - makeFullScreen(); + public void showSelectionInputDialog(String[] optionList, int selectedIdx) { + runOnUiThread(() -> showSelectionInputDialogUI(optionList, selectedIdx)); } - @Override - protected void onResume() { - super.onResume(); - makeFullScreen(); - } - - @Override - public void onBackPressed() { - // Ignore the back press so Minetest can handle it - } - - public void showDialog(String acceptButton, String hint, String current, int editType) { - runOnUiThread(() -> showDialogUI(hint, current, editType)); - } - - private void showDialogUI(String hint, String current, int editType) { + private void showTextInputDialogUI(String hint, String current, int editType) { + lastDialogType = DialogType.TEXT_INPUT; + inputDialogState = DialogState.DIALOG_SHOWN; final AlertDialog.Builder builder = new AlertDialog.Builder(this); LinearLayout container = new LinearLayout(this); container.setOrientation(LinearLayout.VERTICAL); @@ -114,7 +126,7 @@ public class GameActivity extends NativeActivity { // For multi-line, do not submit the text after pressing Enter key if (keyCode == KeyEvent.KEYCODE_ENTER && editType != 1) { imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); - messageReturnCode = 0; + inputDialogState = DialogState.DIALOG_INPUTTED; messageReturnValue = editText.getText().toString(); alertDialog.dismiss(); return true; @@ -128,29 +140,55 @@ public class GameActivity extends NativeActivity { doneButton.setText(R.string.ime_dialog_done); doneButton.setOnClickListener((view -> { imm.hideSoftInputFromWindow(editText.getWindowToken(), 0); - messageReturnCode = 0; + inputDialogState = DialogState.DIALOG_INPUTTED; messageReturnValue = editText.getText().toString(); alertDialog.dismiss(); })); } alertDialog.setOnCancelListener(dialog -> { getWindow().setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_STATE_ALWAYS_HIDDEN); + inputDialogState = DialogState.DIALOG_CANCELED; messageReturnValue = current; - messageReturnCode = -1; }); alertDialog.show(); editText.requestFocusTryShow(); } - public int getDialogState() { - return messageReturnCode; + public void showSelectionInputDialogUI(String[] optionList, int selectedIdx) { + lastDialogType = DialogType.SELECTION_INPUT; + inputDialogState = DialogState.DIALOG_SHOWN; + final AlertDialog.Builder builder = new AlertDialog.Builder(this); + builder.setSingleChoiceItems(optionList, selectedIdx, (dialog, selection) -> { + inputDialogState = DialogState.DIALOG_INPUTTED; + selectionReturnValue = selection; + dialog.dismiss(); + }); + builder.setOnCancelListener(dialog -> { + inputDialogState = DialogState.DIALOG_CANCELED; + selectionReturnValue = selectedIdx; + }); + AlertDialog alertDialog = builder.create(); + alertDialog.show(); } - public String getDialogValue() { - messageReturnCode = -1; + public int getLastDialogType() { + return lastDialogType.ordinal(); + } + + public int getInputDialogState() { + return inputDialogState.ordinal(); + } + + public String getDialogMessage() { + inputDialogState = DialogState.DIALOG_CANCELED; return messageReturnValue; } + public int getDialogSelection() { + inputDialogState = DialogState.DIALOG_CANCELED; + return selectionReturnValue; + } + public float getDensity() { return getResources().getDisplayMetrics().density; } @@ -165,7 +203,11 @@ public class GameActivity extends NativeActivity { public void openURI(String uri) { Intent browserIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(uri)); - startActivity(browserIntent); + try { + startActivity(browserIntent); + } catch (ActivityNotFoundException e) { + runOnUiThread(() -> Toast.makeText(this, R.string.no_web_browser, Toast.LENGTH_SHORT).show()); + } } public String getUserDataPath() { @@ -217,4 +259,8 @@ public class GameActivity extends NativeActivity { return langCode; } + + public boolean hasPhysicalKeyboard() { + return getContext().getResources().getConfiguration().keyboard != Configuration.KEYBOARD_NOKEYS; + } } diff --git a/android/app/src/main/java/net/minetest/minetest/MainActivity.java b/android/app/src/main/java/net/minetest/minetest/MainActivity.java index d942e93e3..7735e8b3d 100644 --- a/android/app/src/main/java/net/minetest/minetest/MainActivity.java +++ b/android/app/src/main/java/net/minetest/minetest/MainActivity.java @@ -20,23 +20,30 @@ with this program; if not, write to the Free Software Foundation, Inc., package net.minetest.minetest; +import android.annotation.SuppressLint; +import android.app.NotificationChannel; +import android.app.NotificationManager; import android.content.BroadcastReceiver; import android.content.Context; import android.content.Intent; import android.content.IntentFilter; import android.content.SharedPreferences; +import android.os.Build; import android.os.Bundle; import android.view.View; import android.widget.ProgressBar; import android.widget.TextView; import android.widget.Toast; +import androidx.annotation.RequiresApi; import androidx.annotation.StringRes; import androidx.appcompat.app.AppCompatActivity; import static net.minetest.minetest.UnzipService.*; public class MainActivity extends AppCompatActivity { + public static final String NOTIFICATION_CHANNEL_ID = "Minetest channel"; + private final static int versionCode = BuildConfig.VERSION_CODE; private static final String SETTINGS = "MinetestSettings"; private static final String TAG_VERSION_CODE = "versionCode"; @@ -77,16 +84,27 @@ public class MainActivity extends AppCompatActivity { } }; + @SuppressLint("UnspecifiedRegisterReceiverFlag") @Override public void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); + IntentFilter filter = new IntentFilter(ACTION_UPDATE); - registerReceiver(myReceiver, filter); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + registerReceiver(myReceiver, filter, RECEIVER_NOT_EXPORTED); + } else { + registerReceiver(myReceiver, filter); + } + mProgressBar = findViewById(R.id.progressBar); mTextView = findViewById(R.id.textView); sharedPreferences = getSharedPreferences(SETTINGS, Context.MODE_PRIVATE); + checkAppVersion(); + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) + createNotificationChannel(); } private void checkAppVersion() { @@ -114,6 +132,28 @@ public class MainActivity extends AppCompatActivity { startActivity(intent); } + @RequiresApi(Build.VERSION_CODES.O) + private void createNotificationChannel() { + NotificationManager notifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); + if (notifyManager == null) + return; + + NotificationChannel notifyChannel = new NotificationChannel( + NOTIFICATION_CHANNEL_ID, + getString(R.string.notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ); + notifyChannel.setDescription(getString(R.string.notification_channel_description)); + // Configure the notification channel without sound set + notifyChannel.setSound(null, null); + notifyChannel.enableLights(false); + notifyChannel.enableVibration(false); + + // It is fine to always create the notification channel because creating a channel + // with the same ID is the same as overriding it (only its name and description). + notifyManager.createNotificationChannel(notifyChannel); + } + @Override public void onBackPressed() { // Prevent abrupt interruption when copy game files from assets diff --git a/android/app/src/main/java/net/minetest/minetest/UnzipService.java b/android/app/src/main/java/net/minetest/minetest/UnzipService.java index 8c293fa89..261eebb8e 100644 --- a/android/app/src/main/java/net/minetest/minetest/UnzipService.java +++ b/android/app/src/main/java/net/minetest/minetest/UnzipService.java @@ -22,7 +22,6 @@ package net.minetest.minetest; import android.app.IntentService; import android.app.Notification; -import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; import android.content.Context; @@ -58,9 +57,11 @@ public class UnzipService extends IntentService { private String failureMessage; private static boolean isRunning = false; + public static synchronized boolean getIsRunning() { return isRunning; } + private static synchronized void setIsRunning(boolean v) { isRunning = v; } @@ -99,28 +100,13 @@ public class UnzipService extends IntentService { } } + @NonNull private Notification.Builder createNotification() { - String name = "net.minetest.minetest"; - String channelId = "Minetest channel"; - String description = "notifications from Minetest"; Notification.Builder builder; if (mNotifyManager == null) mNotifyManager = (NotificationManager) getSystemService(Context.NOTIFICATION_SERVICE); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - int importance = NotificationManager.IMPORTANCE_LOW; - NotificationChannel mChannel = null; - if (mNotifyManager != null) - mChannel = mNotifyManager.getNotificationChannel(channelId); - if (mChannel == null) { - mChannel = new NotificationChannel(channelId, name, importance); - mChannel.setDescription(description); - // Configure the notification channel, NO SOUND - mChannel.setSound(null, null); - mChannel.enableLights(false); - mChannel.enableVibration(false); - mNotifyManager.createNotificationChannel(mChannel); - } - builder = new Notification.Builder(this, channelId); + builder = new Notification.Builder(this, MainActivity.NOTIFICATION_CHANNEL_ID); } else { builder = new Notification.Builder(this); } @@ -135,9 +121,9 @@ public class UnzipService extends IntentService { PendingIntent intent = PendingIntent.getActivity(this, 0, notificationIntent, pendingIntentFlag); - builder.setContentTitle(getString(R.string.notification_title)) + builder.setContentTitle(getString(R.string.unzip_notification_title)) .setSmallIcon(R.mipmap.ic_launcher) - .setContentText(getString(R.string.notification_description)) + .setContentText(getString(R.string.unzip_notification_description)) .setContentIntent(intent) .setOngoing(true) .setProgress(0, 0, true); @@ -198,8 +184,9 @@ public class UnzipService extends IntentService { } } - private void publishProgress(@Nullable Notification.Builder notificationBuilder, @StringRes int message, int progress) { + private void publishProgress(@Nullable Notification.Builder notificationBuilder, @StringRes int message, int progress) { Intent intentUpdate = new Intent(ACTION_UPDATE); + intentUpdate.setPackage(getPackageName()); intentUpdate.putExtra(ACTION_PROGRESS, progress); intentUpdate.putExtra(ACTION_PROGRESS_MESSAGE, message); if (!isSuccess) diff --git a/android/app/src/main/java/org/libsdl/app/HIDDevice.java b/android/app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 000000000..955df5d14 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,22 @@ +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int sendFeatureReport(byte[] report); + public int sendOutputReport(byte[] report); + public boolean getFeatureReport(byte[] report); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 000000000..ee5521fd5 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,650 @@ +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + public void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } + + @Override + public int sendOutputReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + @Override + public boolean getFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + //Log.v(TAG, "getFeatureReport"); + readCharacteristic(reportCharacteristic); + return true; + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 000000000..e7281fdf2 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -0,0 +1,691 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.PendingIntent; +import android.bluetooth.BluetoothAdapter; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.os.Build; +import android.util.Log; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.content.pm.PackageManager; +import android.hardware.usb.*; +import android.os.Handler; +import android.os.Looper; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.Iterator; +import java.util.List; + +public class HIDDeviceManager { + private static final String TAG = "hidapi"; + private static final String ACTION_USB_PERMISSION = "org.libsdl.app.USB_PERMISSION"; + + private static HIDDeviceManager sManager; + private static int sManagerRefCount = 0; + + public static HIDDeviceManager acquire(Context context) { + if (sManagerRefCount == 0) { + sManager = new HIDDeviceManager(context); + } + ++sManagerRefCount; + return sManager; + } + + public static void release(HIDDeviceManager manager) { + if (manager == sManager) { + --sManagerRefCount; + if (sManagerRefCount == 0) { + sManager.close(); + sManager = null; + } + } + } + + private Context mContext; + private HashMap mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.commit(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + mContext.registerReceiver(mUsbBroadcast, filter); + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + 0x3537, // GameSir + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol()); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT >= 31 /* Android 12 */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH_CONNECT, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH_CONNECT"); + return; + } + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + mContext.registerReceiver(mBluetoothBroadcast, filter); + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + public void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public int sendOutputReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendOutputReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public int sendFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public boolean getFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "getFeatureReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.getFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceFeatureReport(int deviceID, byte[] report); +} diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 000000000..bfe0cf954 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,309 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + public String getIdentifier() { + return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } + + @Override + public int sendOutputReport(byte[] report) { + int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (r != report.length) { + Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName()); + } + return r; + } + + @Override + public boolean getFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceFeatureReport(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/android/app/src/main/java/org/libsdl/app/SDL.java b/android/app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 000000000..44c21c1c7 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,86 @@ +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/android/app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 000000000..83e3cf657 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2122 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 2; + private static final int SDL_MINOR_VERSION = 30; + private static final int SDL_MICRO_VERSION = 1; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unkown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 21) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 18) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unkown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentOrientation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static DummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API12(); + } + } + + return mMotionListener; + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL2". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL2", + // "SDL2_image", + // "SDL2_mixer", + // "SDL2_net", + // "SDL2_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + mCurrentOrientation = SDLActivity.getCurrentOrientation(); + // Only record current orientation + SDLActivity.onNativeOrientationChanged(mCurrentOrientation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getCurrentOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity == null) { + return result; + } + Display display = activity.getWindowManager().getDefaultDisplay(); + + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = SDL_ORIENTATION_PORTRAIT; + break; + + case Surface.ROTATION_90: + result = SDL_ORIENTATION_LANDSCAPE; + break; + + case Surface.ROTATION_180: + result = SDL_ORIENTATION_PORTRAIT_FLIPPED; + break; + + case Surface.ROTATION_270: + result = SDL_ORIENTATION_LANDSCAPE_FLIPPED; + break; + } + + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onLowMemory() { + Log.v(TAG, "onLowMemory()"); + super.onLowMemory(); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + SDLActivity.mSDLThread.join(); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + return super.dispatchKeyEvent(event); + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + static final int COMMAND_CHANGE_TITLE = 1; + static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + static final int COMMAND_TEXTEDIT_HIDE = 3; + static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full sensor */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { +/* + if (Build.VERSION.SDK_INT >= 24) { + if (mSingleton == null) { + return true; + } + + if (mSingleton.isInMultiWindowMode()) { + return false; + } + + if (mSingleton.isInPictureInPictureMode()) { + return false; + } + } + + return true; +*/ + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV"); + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static DisplayMetrics getDisplayDPI() { + return getContext().getResources().getDisplayMetrics(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int x, y, w, h; + + public ShowTextInputTask(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new DummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE || + /* + * CUSTOM ADDITION FOR MINETEST + * should be upstreamed + */ + (source & InputDevice.SOURCE_MOUSE_RELATIVE) == InputDevice.SOURCE_MOUSE_RELATIVE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + int touchDevId = device.getId(); + /* + * Prevent id to be -1, since it's used in SDL internal for synthetic events + * Appears when using Android emulator, eg: + * adb shell input mouse tap 100 100 + * adb shell input touchscreen tap 100 100 + */ + if (touchDevId < 0) { + touchDevId -= 1; + } + nativeAddTouch(touchDevId, device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray