Compare commits
7 Commits
8712269b51
...
v0.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
| 0f59898bb6 | |||
| 4e47e0a989 | |||
| ac640762d7 | |||
| 3da137da09 | |||
| 3034703994 | |||
| 66f02a0c61 | |||
| 9e2c9ffbe6 |
@@ -45,6 +45,7 @@ jobs:
|
||||
command -v cmake >/dev/null 2>&1 || { echo "PREFLIGHT FAIL: cmake not in PATH" >&2; exit 1; }
|
||||
[ -n "${QUESTSHOCK_BUILD_IMAGE:-}" ] || { echo "PREFLIGHT FAIL: QUESTSHOCK_BUILD_IMAGE not set - not running inside the questshock build-image?" >&2; exit 1; }
|
||||
[ -d /opt/prebuilt/built_sdl ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/built_sdl missing" >&2; exit 1; }
|
||||
[ -d /opt/prebuilt/android/gl4es ] || { echo "PREFLIGHT FAIL: /opt/prebuilt/android/gl4es missing - runner is using a build-image older than the Android/Quest layer" >&2; exit 1; }
|
||||
|
||||
- name: Build package
|
||||
run: make package
|
||||
@@ -54,17 +55,33 @@ jobs:
|
||||
# instance's artifact storage doesn't support (GHESNotSupportedError) - v3 works.
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: questshock
|
||||
path: dist/questshock-*-linux-*.tar.gz
|
||||
name: shockolate
|
||||
path: dist/shockolate-*-linux-*.tar.gz
|
||||
|
||||
- name: Build Quest APK
|
||||
# QUESTSHOCK_BUILD_IMAGE is already set (see Preflight above), so
|
||||
# this runs build-apk.sh directly instead of trying to docker-run
|
||||
# the image again - same reasoning as `make package` above.
|
||||
# Unlike the fully offline engine/package build, this needs
|
||||
# network access for Gradle/AGP's own dependency resolution - a
|
||||
# scoped, accepted exception (see build-image/Dockerfile).
|
||||
run: make apk
|
||||
|
||||
- name: Upload APK artifact
|
||||
uses: actions/upload-artifact@v3
|
||||
with:
|
||||
name: questshock-quest-apk
|
||||
path: dist/questshock-*-android-*.apk
|
||||
|
||||
- name: Publish to dl.ladkau.de
|
||||
# Uploads the tarball over SFTP instead of using
|
||||
# Uploads the tarball and APK over SFTP instead of using
|
||||
# actions/upload-artifact (whose zip wrapping can't be disabled).
|
||||
# Only runs on push so PR builds don't publish.
|
||||
if: gitea.event_name == 'push'
|
||||
run: |
|
||||
set -euo pipefail
|
||||
TARBALL="$(ls dist/questshock-*-linux-*.tar.gz)"
|
||||
TARBALL="$(ls dist/shockolate-*-linux-*.tar.gz)"
|
||||
APK="$(ls dist/questshock-*-android-*.apk)"
|
||||
mkdir -p ~/.ssh
|
||||
echo "${{ secrets.DL_SFTP_KEY }}" > ~/.ssh/dl_sftp_key
|
||||
chmod 600 ~/.ssh/dl_sftp_key
|
||||
@@ -73,4 +90,5 @@ jobs:
|
||||
uploader@dl.ladkau.de <<EOF
|
||||
-mkdir files/questshock
|
||||
put $TARBALL files/questshock/$(basename "$TARBALL")
|
||||
put $APK files/questshock/$(basename "$APK")
|
||||
EOF
|
||||
|
||||
@@ -1,3 +1,6 @@
|
||||
# Android studio project files
|
||||
/android/.idea
|
||||
|
||||
# Game assets are not included in the repo
|
||||
/res/assets/setup_system_shock_enhanced*
|
||||
/res/assets/ss_ee/
|
||||
@@ -17,5 +20,15 @@
|
||||
/engine/systemshock
|
||||
/engine/src/Libraries/CMakeFiles/
|
||||
|
||||
# Android build artifacts (generated by build-image/build-apk.sh - see
|
||||
# android/engine-patches/ for why engine/ itself is never touched)
|
||||
/android/engine.properties
|
||||
/android/app/src/main/assets/
|
||||
/android/app/src/main/jniLibs/
|
||||
/android/app/build/
|
||||
/android/.gradle/
|
||||
/android/app/.cxx/
|
||||
/android/local.properties
|
||||
|
||||
# Local registry credentials (see registry.env.example)
|
||||
/registry.env
|
||||
|
||||
@@ -14,7 +14,7 @@ ASSETS_DIR := res/assets/ss_ee
|
||||
BUILD_DIR := build
|
||||
ARCH := $(shell uname -m)
|
||||
|
||||
.PHONY: all dist build-image engine assets package clean
|
||||
.PHONY: all dist build-image engine assets package apk clean
|
||||
|
||||
all: dist
|
||||
|
||||
@@ -65,7 +65,7 @@ dist: engine assets
|
||||
@echo "== Done - run $(DIST_DIR)/run.sh to play =="
|
||||
|
||||
# Builds a versioned, redistributable Linux release tarball at
|
||||
# dist/questshock-<version>-linux-<arch>.tar.gz - everything needed to
|
||||
# dist/shockolate-<version>-linux-<arch>.tar.gz - everything needed to
|
||||
# run except the proprietary game assets (res/GET_ASSETS.txt explains how
|
||||
# to get those instead of shipping res/data/, res/sound/). Version
|
||||
# defaults to the current git tag (vX.Y.Z, tag prefix stripped) - push a
|
||||
@@ -74,20 +74,8 @@ dist: engine assets
|
||||
# placeholder version, with a warning).
|
||||
package: engine
|
||||
@git config --global --add safe.directory "$$(pwd)" 2>/dev/null || true
|
||||
@V="$(VERSION)"; \
|
||||
if [ -z "$$V" ]; then \
|
||||
if TAG=$$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then \
|
||||
V=$${TAG#v}; \
|
||||
else \
|
||||
V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \
|
||||
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $$V (push a tag to drive a real release version)" >&2; \
|
||||
fi; \
|
||||
fi; \
|
||||
case "$$V" in \
|
||||
[0-9]*.[0-9]*.[0-9]*) ;; \
|
||||
*) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;; \
|
||||
esac; \
|
||||
PKG_NAME="questshock-$$V-linux-$(ARCH)"; \
|
||||
@V="$$(VERSION="$(VERSION)" ./build-image/version.sh)"; \
|
||||
PKG_NAME="shockolate-$$V-linux-$(ARCH)"; \
|
||||
PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \
|
||||
echo "Packaging $$PKG_NAME"; \
|
||||
rm -rf "$$PKG_STAGE"; \
|
||||
@@ -107,8 +95,23 @@ package: engine
|
||||
rm -rf "$(BUILD_DIR)/package"; \
|
||||
echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz"
|
||||
|
||||
# Builds the Quest APK (see build-image/build-apk.sh and
|
||||
# android/engine-patches/ - engine/ itself is never modified; a patch is
|
||||
# applied to a scratch copy at build time instead). Same
|
||||
# QUESTSHOCK_BUILD_IMAGE detection as `engine`. Unlike every other target
|
||||
# here, this needs network access at build time (Gradle/AGP's own
|
||||
# dependency resolution) - see build-image/Dockerfile.
|
||||
apk:
|
||||
@if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \
|
||||
bash build-image/build-apk.sh; \
|
||||
else \
|
||||
./run-image.sh bash build-image/build-apk.sh; \
|
||||
fi
|
||||
|
||||
clean:
|
||||
rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \
|
||||
engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \
|
||||
engine/cmake_install.cmake engine/Makefile engine/systemshock \
|
||||
engine/src/Libraries/CMakeFiles
|
||||
engine/src/Libraries/CMakeFiles \
|
||||
android/engine.properties android/app/src/main/assets android/app/src/main/jniLibs \
|
||||
android/app/build android/.gradle android/app/.cxx
|
||||
|
||||
@@ -7,3 +7,6 @@ under the GNU GPLv3 - see LICENSE.Shockolate. It is included unchanged
|
||||
and is not relicensed by this project's MIT license.
|
||||
|
||||
Game assets are not included - see res/GET_ASSETS.txt.
|
||||
|
||||
The Quest build bundles GL4ES (https://github.com/ptitSeb/gl4es),
|
||||
prebuilt unmodified as lib/arm64-v8a/libGL.so, which is MIT-licensed.
|
||||
|
||||
@@ -22,8 +22,14 @@ a cross-platform port of the original game.
|
||||
- `res/run.sh` - the launcher script, copied into `dist/` on build.
|
||||
- `Makefile` - assembles `dist/`, a self-contained runnable copy of the
|
||||
game, out of the compiled engine and the extracted assets. Also builds
|
||||
`dist/questshock-<version>-linux-<arch>.tar.gz`, a redistributable
|
||||
package that omits the proprietary game assets (`make package`).
|
||||
`dist/shockolate-<version>-linux-<arch>.tar.gz`, a redistributable
|
||||
package that omits the proprietary game assets (`make package`), and
|
||||
`dist/questshock-<version>-android-arm64.apk` for the Quest (`make apk`).
|
||||
- `android/` - the Quest app (Java `SDLActivity` glue, Gradle project).
|
||||
`android/engine-patches/` holds the one small patch needed to build
|
||||
`engine/` as an Android shared library instead of a desktop executable
|
||||
- applied to a scratch copy at build time; `engine/` itself is never
|
||||
modified.
|
||||
|
||||
## Building
|
||||
|
||||
@@ -58,25 +64,92 @@ game data lives inside it in a zip-format `sshock.kpf`) into
|
||||
they aren't installed locally it falls back to running the extraction in
|
||||
a throwaway Docker container instead.
|
||||
|
||||
If you already have the game installed instead (on Windows, or via
|
||||
Wine/Proton on Linux), you don't need the installer or the script at
|
||||
all - just copy its `res/data/` and `res/sound/` folders directly into
|
||||
`res/assets/ss_ee/data/` and `res/assets/ss_ee/sound/`. That's exactly
|
||||
the same layout `extract_assets.sh` produces, so `make dist`/`make
|
||||
package`/`make apk` pick it up the same way either way.
|
||||
|
||||
## Packaging
|
||||
|
||||
`make package` builds `dist/questshock-<version>-linux-<arch>.tar.gz`: the
|
||||
`make package` builds `dist/shockolate-<version>-linux-<arch>.tar.gz`: the
|
||||
compiled binary, its runtime libraries, shaders, a default MIDI
|
||||
soundfont, license information, and `res/GET_ASSETS.txt` in place of the
|
||||
actual game data (which the tarball never includes). Version comes from
|
||||
the current git tag (push a `vX.Y.Z` tag to drive a release); without one
|
||||
it builds an untagged `0.0.0-dev+<sha>` placeholder.
|
||||
it builds an untagged `0.0.0-dev+<sha>` placeholder. `make apk` (below)
|
||||
is versioned identically, via the same `build-image/version.sh`.
|
||||
|
||||
A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds this
|
||||
package on every push, using the build-image as its container (so no
|
||||
extra setup is needed in CI beyond the image itself), and publishes the
|
||||
resulting tarball to dl.ladkau.de.
|
||||
|
||||
## Playing on Meta Quest
|
||||
|
||||
`make apk` builds `dist/questshock-<version>-android-arm64.apk` - a plain
|
||||
(non-VR) Android app that runs as a flat, floating panel in the Quest's
|
||||
Home environment, same as any other sideloaded Android app. It's not a
|
||||
head-tracked 6DoF VR port (that's a much larger, separate undertaking);
|
||||
play with a Bluetooth mouse/keyboard connected to the headset.
|
||||
|
||||
1. Install the APK with [SideQuest](https://sidequestvr.com/) (or `adb
|
||||
install`).
|
||||
2. Launch it once. It'll ask for storage permission, then create
|
||||
`/sdcard/questshock/` and extract its own bundled files (shaders, a
|
||||
default MIDI soundfont) there - `res/data/` and `res/sound/` are
|
||||
deliberately left missing, since that's the proprietary game data.
|
||||
3. With the Quest connected to a PC, use SideQuest's file browser (or any
|
||||
MTP file manager) to copy your own `res/data/` and `res/sound/` (see
|
||||
`/sdcard/questshock/GET_ASSETS_QUEST.txt`, extracted in step 2, for
|
||||
exactly what's needed and where it comes from) into
|
||||
`/sdcard/questshock/res/`.
|
||||
4. Launch it again.
|
||||
|
||||
### Building natively in Android Studio
|
||||
|
||||
`make apk` always compiles the engine and links the APK inside the
|
||||
Docker build-image - convenient for CI/CLI builds, but Android Studio
|
||||
can't attach a debugger to (or get IDE code-intelligence for) a build
|
||||
that happens inside a container it isn't running.
|
||||
|
||||
To have Android Studio compile and deploy `android/` itself instead:
|
||||
|
||||
```sh
|
||||
./run-image.sh bash build-image/prepare-android-project.sh --host-paths
|
||||
```
|
||||
|
||||
This stages everything `make apk` normally stages (a scratch, patched
|
||||
copy of `engine/`; the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es
|
||||
prebuilts; bundled assets) - the same as `build-apk.sh`'s own prep step -
|
||||
except it writes `android/engine.properties` with paths that resolve on
|
||||
your host filesystem, and additionally exports the prebuilt libraries
|
||||
(otherwise only present inside the image, at `/opt/prebuilt/android`) to
|
||||
`build/android-prebuilt/` so they're visible outside the container too.
|
||||
Re-run it whenever `engine/`, `android/engine-patches/`, or the
|
||||
prebuilt-library versions in `build-image/Dockerfile` change.
|
||||
|
||||
Then open `android/` as a project in Android Studio (JDK 17, NDK
|
||||
`26.1.10909125`, and SDK Platform/Build-Tools 34 installed, matching
|
||||
`build-image/Dockerfile` and `android/app/build.gradle`) and build/run
|
||||
normally - no Docker involved for this part.
|
||||
|
||||
## License
|
||||
|
||||
The original tooling in this repository (the Docker build image, build
|
||||
scripts, Makefile, and asset extraction script) is licensed under the
|
||||
[MIT License](LICENSE).
|
||||
scripts, Makefile, asset extraction script, and the Quest app in
|
||||
`android/` - aside from `org/libsdl/app/`, see below) is licensed under
|
||||
the [MIT License](LICENSE).
|
||||
|
||||
`android/app/src/main/java/org/libsdl/app/` is copied from
|
||||
[SDL2](https://www.libsdl.org/)'s own android-project template and is
|
||||
zlib-licensed, same as SDL2 itself.
|
||||
|
||||
The Quest build also bundles [GL4ES](https://github.com/ptitSeb/gl4es)
|
||||
(`lib/arm64-v8a/libGL.so` in the APK, prebuilt unmodified into the build
|
||||
image), which translates the engine's desktop-style OpenGL calls into
|
||||
GLES/EGL and is MIT-licensed.
|
||||
|
||||
The vendored engine snapshot in `engine/` is
|
||||
[Shockolate](https://github.com/Interrupt/systemshock), which is licensed
|
||||
|
||||
@@ -0,0 +1,155 @@
|
||||
apply plugin: 'com.android.application'
|
||||
|
||||
// Paths written by build-image/prepare-android-project.sh (called by
|
||||
// build-apk.sh, or standalone with --host-paths to prep for a native
|
||||
// build in Android Studio - see README) since they're only known at
|
||||
// prep/build time, not something a checked-in build.gradle can hardcode.
|
||||
// engineDir is the scratch, patched copy of engine/; prebuiltDir is the
|
||||
// Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts - both point
|
||||
// into this container's own paths for build-apk.sh's own gradlew call,
|
||||
// or host-resolvable paths (plus a host-side export of prebuiltDir, since
|
||||
// /opt/prebuilt/android only exists in the image) when prepared with
|
||||
// --host-paths for Android Studio.
|
||||
def engineProps = new Properties()
|
||||
file("${projectDir}/../engine.properties").withInputStream { engineProps.load(it) }
|
||||
def engineDir = engineProps.getProperty('engineDir')
|
||||
if (engineDir == null) {
|
||||
throw new GradleException("android/engine.properties is missing 'engineDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
|
||||
}
|
||||
def prebuiltDir = engineProps.getProperty('prebuiltDir')
|
||||
if (prebuiltDir == null) {
|
||||
throw new GradleException("android/engine.properties is missing 'prebuiltDir' - run via build-apk.sh or prepare-android-project.sh, not gradlew directly")
|
||||
}
|
||||
|
||||
// Set by build-apk.sh (see build-image/version.sh, shared with the
|
||||
// desktop tarball's versioning) via -PquestshockVersionName/Code. Default
|
||||
// here only covers a direct, unsupported `gradlew` invocation.
|
||||
def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev'
|
||||
def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1
|
||||
|
||||
// Auto-refreshes the staged, patched engine/ copy and prebuilt libraries
|
||||
// (see build-image/prepare-android-project.sh --host-paths, and the
|
||||
// engineDir/prebuiltDir properties read above) so a plain Android Studio
|
||||
// build/run can never silently compile against a stale scratch copy after
|
||||
// engine/ or android/engine-patches/ change - previously a manual, easy to
|
||||
// forget re-run. inputs/outputs are declared so Gradle skips the (Docker-
|
||||
// invoking, not free) step entirely when nothing relevant actually changed,
|
||||
// keeping pure-Java edit/run cycles fast.
|
||||
def repoRoot = file("${projectDir}/../..")
|
||||
def stageEngineTask = tasks.register("stageEngine", Exec) {
|
||||
group = "build setup"
|
||||
description = "Refreshes the patched engine/ scratch copy and prebuilt libraries for a native Android Studio build (build-image/prepare-android-project.sh --host-paths)."
|
||||
workingDir repoRoot
|
||||
commandLine "./run-image.sh", "bash", "build-image/prepare-android-project.sh", "--host-paths"
|
||||
|
||||
// /opt/prebuilt/android only ever exists inside the build-image
|
||||
// container itself (baked in at image-build time, never on the host -
|
||||
// see build-image/Dockerfile). Its presence means this build is
|
||||
// build-apk.sh's own gradlew call, running INSIDE that container, which
|
||||
// already staged everything itself in container mode before invoking
|
||||
// gradlew - re-running this task there would try to `docker run` from
|
||||
// inside a container with no docker socket, breaking make apk/CI. Only
|
||||
// a genuine host-side Android Studio build (where this path is absent)
|
||||
// needs this task.
|
||||
onlyIf { !file('/opt/prebuilt/android').isDirectory() }
|
||||
|
||||
inputs.dir("${repoRoot}/engine")
|
||||
inputs.dir("${projectDir}/../engine-patches")
|
||||
inputs.file("${repoRoot}/build-image/Dockerfile")
|
||||
outputs.dir("${repoRoot}/build/android-engine")
|
||||
outputs.dir("${repoRoot}/build/android-prebuilt")
|
||||
outputs.file("${projectDir}/../engine.properties")
|
||||
}
|
||||
|
||||
// preBuild is what every variant's compile/native-build tasks already
|
||||
// transitively depend on, regardless of AGP version's exact CMake task
|
||||
// naming - the simplest reliable hook to run before any of them.
|
||||
afterEvaluate {
|
||||
tasks.named("preBuild").configure {
|
||||
dependsOn stageEngineTask
|
||||
}
|
||||
}
|
||||
|
||||
android {
|
||||
namespace "de.ladkau.questshock"
|
||||
// compileSdk/buildToolsVersion/ndkVersion must all match what
|
||||
// build-image/Dockerfile actually installs - otherwise AGP defaults to
|
||||
// whatever version it itself prefers and tries to download it into the
|
||||
// SDK dir at build time, which isn't writable.
|
||||
compileSdk 34
|
||||
buildToolsVersion "34.0.0"
|
||||
ndkVersion "26.1.10909125"
|
||||
|
||||
defaultConfig {
|
||||
applicationId "de.ladkau.questshock"
|
||||
// 29, not compileSdk's 34, is deliberate - see build-image/Dockerfile's
|
||||
// comment on ANDROID_PLATFORM_VERSION: paired with
|
||||
// requestLegacyExternalStorage in the manifest, this keeps
|
||||
// /sdcard/questshock/ a plain, unrestricted shared folder instead of
|
||||
// Android 11+ scoped storage, on the Quest 2/3/3S/Pro headsets this
|
||||
// targets - not just whatever's the current API level today.
|
||||
minSdkVersion 24
|
||||
targetSdkVersion 29
|
||||
versionCode questshockVersionCode
|
||||
versionName questshockVersionName
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
// engine/CMakeLists.txt's own dependency-selection options
|
||||
// (see engine/CMakeLists.txt near the top): SDL2 via
|
||||
// find_package (our Android SDL2 build installs a real CMake
|
||||
// config, unlike the desktop build's older bundled copy);
|
||||
// SDL2_mixer and FluidSynth via the same build_ext/ BUNDLED
|
||||
// convention the desktop build already uses (populated in the
|
||||
// scratch engine copy by prepare-android-project.sh from
|
||||
// prebuiltDir). ANDROID_PREBUILT_DIR is also read directly by
|
||||
// android/engine-patches/02-android-opengl-es.patch, for gl4es.
|
||||
// The CMAKE_FIND_ROOT_PATH* overrides below are needed
|
||||
// because the NDK toolchain file restricts find_package/
|
||||
// find_path/find_library to its own sysroot by default,
|
||||
// which would otherwise miss our custom-installed SDL2 (see
|
||||
// build-image/Dockerfile's SDL2_mixer build, which hit the
|
||||
// exact same thing).
|
||||
// -Wl,-z,max-page-size=16384: NDK 26 doesn't 16 KB-align ELF
|
||||
// LOAD segments by default (only automatic in NDK 28+) - see
|
||||
// build-image/Dockerfile's ANDROID_16KB_LDFLAGS, which does
|
||||
// the same for the prebuilt SDL2/SDL2_mixer/fluidsynth-lite/
|
||||
// gl4es .so's this links against.
|
||||
arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \
|
||||
"-DANDROID_PREBUILT_DIR=${prebuiltDir}", \
|
||||
"-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \
|
||||
"-DCMAKE_FIND_ROOT_PATH=${prebuiltDir}/sdl2", \
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \
|
||||
"-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \
|
||||
"-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \
|
||||
"-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c"
|
||||
abiFilters 'arm64-v8a'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
externalNativeBuild {
|
||||
cmake {
|
||||
path file("${engineDir}/CMakeLists.txt")
|
||||
}
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
release {
|
||||
minifyEnabled false
|
||||
}
|
||||
}
|
||||
|
||||
lint {
|
||||
abortOnError false
|
||||
}
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// ActivityCompat.checkSelfPermission/requestPermissions in
|
||||
// QuestShockActivity - the plain android.app.Activity APIs work the
|
||||
// same way without androidx, but ActivityCompat is the standard,
|
||||
// documented way to request runtime permissions safely.
|
||||
implementation 'androidx.core:core:1.12.0'
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:installLocation="auto">
|
||||
|
||||
<!-- OpenGL ES 2.0 -->
|
||||
<uses-feature android:glEsVersion="0x00020000" />
|
||||
|
||||
<uses-feature android:name="android.hardware.touchscreen" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.bluetooth" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.gamepad" android:required="false" />
|
||||
<uses-feature android:name="android.hardware.usb.host" android:required="false" />
|
||||
<!-- External mouse input events -->
|
||||
<uses-feature android:name="android.hardware.type.pc" android:required="false" />
|
||||
|
||||
<!-- Plain, unrestricted /sdcard access for the res/data,res/sound the
|
||||
user drops in and the shaders/soundfont this app extracts there on
|
||||
first run - see res/GET_ASSETS_QUEST.txt and QuestShockActivity.
|
||||
requestLegacyExternalStorage below (paired with targetSdkVersion 29
|
||||
in build.gradle) is what keeps this working instead of Android 11+
|
||||
scoped storage. -->
|
||||
<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
|
||||
<uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
|
||||
|
||||
<application android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:allowBackup="true"
|
||||
android:requestLegacyExternalStorage="true"
|
||||
android:theme="@android:style/Theme.NoTitleBar.Fullscreen"
|
||||
android:hardwareAccelerated="true" >
|
||||
|
||||
<activity android:name="de.ladkau.questshock.QuestShockActivity"
|
||||
android:label="@string/app_name"
|
||||
android:alwaysRetainTaskState="true"
|
||||
android:launchMode="singleInstance"
|
||||
android:configChanges="layoutDirection|locale|orientation|uiMode|screenLayout|screenSize|smallestScreenSize|keyboard|keyboardHidden|navigation"
|
||||
android:preferMinimalPostProcessing="true"
|
||||
android:screenOrientation="landscape"
|
||||
android:exported="true"
|
||||
>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.hardware.usb.action.USB_DEVICE_ATTACHED" />
|
||||
</intent-filter>
|
||||
<!-- Without this, Quest's Home shell picks its own default 2D
|
||||
panel shape for this activity - a tall portrait rectangle,
|
||||
which crops a 4:3 landscape game like System Shock's classic
|
||||
640x480 down to a sliver. defaultWidth/defaultHeight (the
|
||||
standard Android multi-window sizing hint, which Quest's
|
||||
panel system honors for 2D apps) requests a properly
|
||||
landscape, 4:3-ish panel instead; minWidth/minHeight keep it
|
||||
from being resized below the game's native resolution. -->
|
||||
<layout android:defaultWidth="1280dp"
|
||||
android:defaultHeight="960dp"
|
||||
android:minWidth="640dp"
|
||||
android:minHeight="480dp"
|
||||
android:gravity="center" />
|
||||
</activity>
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -0,0 +1,15 @@
|
||||
// android.system.Os has no public chdir() (confirmed against the actual
|
||||
// API 34 stub jar - Java has no way to change a process's working
|
||||
// directory at all). This is the one bit of native code QuestShockActivity
|
||||
// needs, compiled into the same "main" library as engine/ (see
|
||||
// android/engine-patches/01-android-shared-lib.patch's ANDROID_EXTRA_SOURCES)
|
||||
// - engine/ itself is never modified.
|
||||
#include <jni.h>
|
||||
#include <unistd.h>
|
||||
|
||||
JNIEXPORT void JNICALL
|
||||
Java_de_ladkau_questshock_QuestShockActivity_nativeChdir(JNIEnv *env, jclass clazz, jstring path) {
|
||||
const char *cpath = (*env)->GetStringUTFChars(env, path, NULL);
|
||||
chdir(cpath);
|
||||
(*env)->ReleaseStringUTFChars(env, path, cpath);
|
||||
}
|
||||
@@ -0,0 +1,270 @@
|
||||
package de.ladkau.questshock;
|
||||
|
||||
import android.Manifest;
|
||||
import android.app.AlertDialog;
|
||||
import android.content.Context;
|
||||
import android.content.pm.PackageManager;
|
||||
import android.content.res.AssetManager;
|
||||
import android.os.Bundle;
|
||||
import android.os.Handler;
|
||||
import android.os.Looper;
|
||||
import android.util.Log;
|
||||
import android.view.SurfaceHolder;
|
||||
import androidx.core.app.ActivityCompat;
|
||||
import java.io.File;
|
||||
import java.io.FileOutputStream;
|
||||
import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.OutputStream;
|
||||
import org.libsdl.app.SDLActivity;
|
||||
import org.libsdl.app.SDLSurface;
|
||||
|
||||
/**
|
||||
* Prepares /sdcard/questshock/ the way Shockolate's plain relative-path
|
||||
* file I/O ("res/data/...", confirmed via grep - none of it goes through
|
||||
* SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile
|
||||
* doesn't apply here) expects to find it - see android/engine-patches/ for
|
||||
* why this is done from Java instead of patching engine/ itself, which is
|
||||
* never modified.
|
||||
*
|
||||
* super.onCreate() (SDLActivity's) must always run first and unconditionally
|
||||
* - Android throws SuperNotCalledException otherwise, checked right after
|
||||
* onCreate() returns, regardless of what this subclass does afterwards.
|
||||
* SDLActivity's own onCreate() only loads libraries, sets up JNI and the
|
||||
* surface - the actual native SDL_main thread doesn't start until later, from
|
||||
* one of several lifecycle paths (onResume(), onWindowFocusChanged(), and
|
||||
* SDLSurface.surfaceChanged() - see GameSurface below for why that last one
|
||||
* needs its own fix) - so it's safe to do our own checks (and set
|
||||
* SDLActivity.mBrokenLibraries, which most - but not all - of those paths
|
||||
* gate on) afterwards.
|
||||
*/
|
||||
public class QuestShockActivity extends SDLActivity {
|
||||
private static final String TAG = "QuestShock";
|
||||
private static final int PERMISSION_REQUEST_STORAGE = 1;
|
||||
private static final String GAME_DIR = "/sdcard/questshock";
|
||||
|
||||
// Loaded here (rather than waiting for SDLActivity's own
|
||||
// getLibraries()/loadLibraries(), which only runs from super.onCreate())
|
||||
// purely to get nativeChdir() below - Android's dynamic linker resolves
|
||||
// "main"'s own dependencies (SDL2, SDL2_mixer, fluidsynth) from the
|
||||
// APK's native library directory regardless of Java-side load order, so
|
||||
// loading it early here is safe. SDLActivity loading "main" again later
|
||||
// is a harmless no-op (System.loadLibrary is idempotent per
|
||||
// ClassLoader).
|
||||
static {
|
||||
System.loadLibrary("main");
|
||||
}
|
||||
|
||||
// See android/app/src/main/cpp/questshock_native.c - android.system.Os
|
||||
// has no public chdir() (confirmed against the actual API 34 stub jar).
|
||||
private static native void nativeChdir(String path);
|
||||
|
||||
@Override
|
||||
protected String[] getLibraries() {
|
||||
return new String[] {
|
||||
"SDL2",
|
||||
"SDL2_mixer",
|
||||
"fluidsynth",
|
||||
"main"
|
||||
};
|
||||
}
|
||||
|
||||
// SDLSurface.surfaceChanged() (org/libsdl/app/, vendored from SDL2's own
|
||||
// template) starts the native SDL thread directly - unlike onResume()/
|
||||
// onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries
|
||||
// first. Since surfaceChanged() fires on essentially every launch
|
||||
// regardless of that flag, setting mBrokenLibraries alone (see onCreate()/
|
||||
// setUpGameDirAndContinue()) does NOT actually stop the engine from
|
||||
// starting - confirmed on-device: the missing-assets crash still happened
|
||||
// with mBrokenLibraries set, from exactly this path. Route through a
|
||||
// subclass that adds the missing check instead of patching the vendored
|
||||
// file directly.
|
||||
private static class GameSurface extends SDLSurface {
|
||||
GameSurface(Context context) {
|
||||
super(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) {
|
||||
if (SDLActivity.mBrokenLibraries) {
|
||||
return;
|
||||
}
|
||||
super.surfaceChanged(holder, format, width, height);
|
||||
}
|
||||
|
||||
// Diagnostic only (see OpenGL.cc's matching log) - Android's real,
|
||||
// legitimate signal for "this View's laid-out size actually
|
||||
// changed" is onSizeChanged(), fired by the framework itself, not
|
||||
// something we have to poll or guess about. If Quest's Home shell
|
||||
// settles a freshly-launched panel into its final <layout> size via
|
||||
// a genuine later layout pass, this is where that would show up -
|
||||
// confirming whether a real event exists to hook, before writing
|
||||
// any fix around it.
|
||||
@Override
|
||||
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
|
||||
super.onSizeChanged(w, h, oldw, oldh);
|
||||
Log.i(TAG, "GameSurface.onSizeChanged() " + oldw + "x" + oldh + " -> " + w + "x" + h);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
protected SDLSurface createSDLSurface(Context context) {
|
||||
return new GameSurface(context);
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void onCreate(Bundle savedInstanceState) {
|
||||
super.onCreate(savedInstanceState);
|
||||
if (SDLActivity.mBrokenLibraries) {
|
||||
// SDLActivity's own onCreate() already showed its "SDL Error"
|
||||
// dialog for this - nothing left for us to do.
|
||||
return;
|
||||
}
|
||||
// Provisionally block the native engine from starting - cleared only
|
||||
// once setUpGameDirAndContinue() confirms game data is present. Must
|
||||
// happen before requestPermissions() below: the storage-permission
|
||||
// dialog closing can fire onWindowFocusChanged(true) - which starts
|
||||
// the native SDLThread - before the async onRequestPermissionsResult()
|
||||
// callback (which is what actually calls setUpGameDirAndContinue())
|
||||
// gets a chance to run, so mBrokenLibraries has to already be true
|
||||
// going into that race, not set afterwards.
|
||||
SDLActivity.mBrokenLibraries = true;
|
||||
if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE)
|
||||
!= PackageManager.PERMISSION_GRANTED) {
|
||||
ActivityCompat.requestPermissions(this,
|
||||
new String[] { Manifest.permission.READ_EXTERNAL_STORAGE, Manifest.permission.WRITE_EXTERNAL_STORAGE },
|
||||
PERMISSION_REQUEST_STORAGE);
|
||||
return;
|
||||
}
|
||||
setUpGameDirAndContinue();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onRequestPermissionsResult(int requestCode, String[] permissions, int[] grantResults) {
|
||||
if (requestCode == PERMISSION_REQUEST_STORAGE) {
|
||||
setUpGameDirAndContinue();
|
||||
return;
|
||||
}
|
||||
super.onRequestPermissionsResult(requestCode, permissions, grantResults);
|
||||
}
|
||||
|
||||
private void setUpGameDirAndContinue() {
|
||||
File gameDir = new File(GAME_DIR);
|
||||
if (!gameDir.exists() && !gameDir.mkdirs()) {
|
||||
Log.e(TAG, "Could not create " + GAME_DIR + " - res/data and res/sound will not be found");
|
||||
}
|
||||
|
||||
// Extract our own bundled build artifacts (shaders, the soundfont,
|
||||
// and the "get assets" instructions) - never the proprietary game
|
||||
// data, which the user must supply themselves (see
|
||||
// res/GET_ASSETS_QUEST.txt). Only done once: if res/GET_ASSETS_QUEST.txt
|
||||
// is already there, assume a previous run already extracted everything.
|
||||
File marker = new File(gameDir, "GET_ASSETS_QUEST.txt");
|
||||
if (!marker.exists()) {
|
||||
copyAssetDir("shaders", new File(gameDir, "shaders"));
|
||||
copyAssetFile("res/soundfont.sf2", new File(gameDir, "res/soundfont.sf2"));
|
||||
copyAssetFile("GET_ASSETS_QUEST.txt", marker);
|
||||
}
|
||||
|
||||
// Shockolate's engine/ is never patched to check this itself (see the
|
||||
// class doc above) - it just does plain fopen("res/data/...", ...)
|
||||
// and, on a fresh install with no game data copied in yet, that
|
||||
// fails deep inside startup (init_popups(), which doesn't NULL-check
|
||||
// the load) as a hard native crash instead of a message. Catch the
|
||||
// missing-data case here instead, before Shockolate's native
|
||||
// SDL_main ever starts.
|
||||
if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) {
|
||||
// mBrokenLibraries is already true (set in onCreate()) - leave it
|
||||
// that way. Every path that starts the native SDL_main thread
|
||||
// (onWindowFocusChanged(), resumeNativeThread(), etc.) already
|
||||
// checks this flag before doing anything native, so this
|
||||
// reliably prevents the engine from starting without having to
|
||||
// duplicate all of SDLActivity's own lifecycle guards ourselves.
|
||||
showMissingAssetsDialog();
|
||||
return;
|
||||
}
|
||||
|
||||
// chdir() is process-wide, not per-thread - already in effect for
|
||||
// every thread (including the one that will run Shockolate's own
|
||||
// SDL_main) by the time the native thread actually starts. Done
|
||||
// before clearing mBrokenLibraries below so the engine can never
|
||||
// start pre-chdir.
|
||||
nativeChdir(GAME_DIR);
|
||||
|
||||
// Assets confirmed present - safe to let the native engine start now.
|
||||
SDLActivity.mBrokenLibraries = false;
|
||||
}
|
||||
|
||||
private static boolean isNonEmptyDir(File dir) {
|
||||
String[] entries = dir.list();
|
||||
return entries != null && entries.length > 0;
|
||||
}
|
||||
|
||||
// Same single-button, non-cancelable pattern as SDLActivity's own
|
||||
// "broken libraries" dialog (org/libsdl/app/SDLActivity.java) - there's
|
||||
// no game to start without this data, so the only way forward is to
|
||||
// close, copy the assets, and relaunch. Message text mirrors
|
||||
// res/assets/GET_ASSETS_QUEST.txt (also extracted to GAME_DIR) so the
|
||||
// user isn't sent hunting for a second file just to read it the first
|
||||
// time - it points back there at the end in case they need it again.
|
||||
private void showMissingAssetsDialog() {
|
||||
new AlertDialog.Builder(this)
|
||||
.setTitle("Game data missing")
|
||||
.setMessage("This app does not include System Shock's game data - it's "
|
||||
+ "copyrighted, proprietary content that can't be redistributed. To "
|
||||
+ "play, you need to own a copy of System Shock: Enhanced Edition (e.g. "
|
||||
+ "from gog.com), installed (on Windows, or via Wine/Proton on Linux).\n\n"
|
||||
+ "Using SideQuest (or any MTP file browser) with your Quest connected, "
|
||||
+ "copy its res/data/ and res/sound/ folders directly into:\n"
|
||||
+ GAME_DIR + "/res/data/\n"
|
||||
+ GAME_DIR + "/res/sound/\n"
|
||||
+ "They already contain everything needed, in the right layout - no "
|
||||
+ "merging or extraction required.\n\n"
|
||||
+ "Then relaunch.\n\n"
|
||||
+ "(These instructions are also in GET_ASSETS_QUEST.txt in the "
|
||||
+ "questshock folder on this device, if you need to read them again.)")
|
||||
.setCancelable(false)
|
||||
.setPositiveButton("Exit", (dialog, which) -> finish())
|
||||
.create()
|
||||
.show();
|
||||
}
|
||||
|
||||
private void copyAssetFile(String assetPath, File dest) {
|
||||
if (dest.exists()) {
|
||||
return;
|
||||
}
|
||||
File parent = dest.getParentFile();
|
||||
if (parent != null) {
|
||||
parent.mkdirs();
|
||||
}
|
||||
AssetManager assets = getAssets();
|
||||
try (InputStream in = assets.open(assetPath);
|
||||
OutputStream out = new FileOutputStream(dest)) {
|
||||
byte[] buf = new byte[64 * 1024];
|
||||
int n;
|
||||
while ((n = in.read(buf)) > 0) {
|
||||
out.write(buf, 0, n);
|
||||
}
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Could not extract asset " + assetPath + " to " + dest, e);
|
||||
}
|
||||
}
|
||||
|
||||
private void copyAssetDir(String assetDir, File destDir) {
|
||||
AssetManager assets = getAssets();
|
||||
String[] entries;
|
||||
try {
|
||||
entries = assets.list(assetDir);
|
||||
} catch (IOException e) {
|
||||
Log.e(TAG, "Could not list asset dir " + assetDir, e);
|
||||
return;
|
||||
}
|
||||
if (entries == null || entries.length == 0) {
|
||||
return;
|
||||
}
|
||||
destDir.mkdirs();
|
||||
for (String entry : entries) {
|
||||
copyAssetFile(assetDir + "/" + entry, new File(destDir, entry));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<GattOperation> 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<GattOperation>();
|
||||
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();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,684 @@
|
||||
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<Integer, HIDDevice> mDevicesById = new HashMap<Integer, HIDDevice>();
|
||||
private HashMap<BluetoothDevice, HIDDeviceBLESteamController> mBluetoothDevices = new HashMap<BluetoothDevice, HIDDeviceBLESteamController>();
|
||||
private int mNextDeviceId = 0;
|
||||
private SharedPreferences mSharedPreferences = null;
|
||||
private boolean mIsChromebook = false;
|
||||
private UsbManager mUsbManager;
|
||||
private Handler mHandler;
|
||||
private BluetoothManager mBluetoothManager;
|
||||
private List<BluetoothDevice> 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
|
||||
};
|
||||
|
||||
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<Integer> devices = new ArrayList<Integer>();
|
||||
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 <= 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<BluetoothDevice>();
|
||||
|
||||
// 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<BluetoothDevice> disconnected = new ArrayList<BluetoothDevice>();
|
||||
ArrayList<BluetoothDevice> connected = new ArrayList<BluetoothDevice>();
|
||||
|
||||
List<BluetoothDevice> 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);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,514 @@
|
||||
package org.libsdl.app;
|
||||
|
||||
import android.content.Context;
|
||||
import android.media.AudioDeviceCallback;
|
||||
import android.media.AudioDeviceInfo;
|
||||
import android.media.AudioFormat;
|
||||
import android.media.AudioManager;
|
||||
import android.media.AudioRecord;
|
||||
import android.media.AudioTrack;
|
||||
import android.media.MediaRecorder;
|
||||
import android.os.Build;
|
||||
import android.util.Log;
|
||||
|
||||
import java.util.Arrays;
|
||||
|
||||
public class SDLAudioManager {
|
||||
protected static final String TAG = "SDLAudio";
|
||||
|
||||
protected static AudioTrack mAudioTrack;
|
||||
protected static AudioRecord mAudioRecord;
|
||||
protected static Context mContext;
|
||||
|
||||
private static final int[] NO_DEVICES = {};
|
||||
|
||||
private static AudioDeviceCallback mAudioDeviceCallback;
|
||||
|
||||
public static void initialize() {
|
||||
mAudioTrack = null;
|
||||
mAudioRecord = null;
|
||||
mAudioDeviceCallback = null;
|
||||
|
||||
if(Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */)
|
||||
{
|
||||
mAudioDeviceCallback = new AudioDeviceCallback() {
|
||||
@Override
|
||||
public void onAudioDevicesAdded(AudioDeviceInfo[] addedDevices) {
|
||||
Arrays.stream(addedDevices).forEach(deviceInfo -> addAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAudioDevicesRemoved(AudioDeviceInfo[] removedDevices) {
|
||||
Arrays.stream(removedDevices).forEach(deviceInfo -> removeAudioDevice(deviceInfo.isSink(), deviceInfo.getId()));
|
||||
}
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
public static void setContext(Context context) {
|
||||
mContext = context;
|
||||
if (context != null) {
|
||||
registerAudioDeviceCallback();
|
||||
}
|
||||
}
|
||||
|
||||
public static void release(Context context) {
|
||||
unregisterAudioDeviceCallback(context);
|
||||
}
|
||||
|
||||
// Audio
|
||||
|
||||
protected static String getAudioFormatString(int audioFormat) {
|
||||
switch (audioFormat) {
|
||||
case AudioFormat.ENCODING_PCM_8BIT:
|
||||
return "8-bit";
|
||||
case AudioFormat.ENCODING_PCM_16BIT:
|
||||
return "16-bit";
|
||||
case AudioFormat.ENCODING_PCM_FLOAT:
|
||||
return "float";
|
||||
default:
|
||||
return Integer.toString(audioFormat);
|
||||
}
|
||||
}
|
||||
|
||||
protected static int[] open(boolean isCapture, int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
|
||||
int channelConfig;
|
||||
int sampleSize;
|
||||
int frameSize;
|
||||
|
||||
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", requested " + desiredFrames + " frames of " + desiredChannels + " channel " + getAudioFormatString(audioFormat) + " audio at " + sampleRate + " Hz");
|
||||
|
||||
/* On older devices let's use known good settings */
|
||||
if (Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
|
||||
if (desiredChannels > 2) {
|
||||
desiredChannels = 2;
|
||||
}
|
||||
}
|
||||
|
||||
/* AudioTrack has sample rate limitation of 48000 (fixed in 5.0.2) */
|
||||
if (Build.VERSION.SDK_INT < 22 /* Android 5.1 (LOLLIPOP_MR1) */) {
|
||||
if (sampleRate < 8000) {
|
||||
sampleRate = 8000;
|
||||
} else if (sampleRate > 48000) {
|
||||
sampleRate = 48000;
|
||||
}
|
||||
}
|
||||
|
||||
if (audioFormat == AudioFormat.ENCODING_PCM_FLOAT) {
|
||||
int minSDKVersion = (isCapture ? 23 /* Android 6.0 (M) */ : 21 /* Android 5.0 (LOLLIPOP) */);
|
||||
if (Build.VERSION.SDK_INT < minSDKVersion) {
|
||||
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
|
||||
}
|
||||
}
|
||||
switch (audioFormat)
|
||||
{
|
||||
case AudioFormat.ENCODING_PCM_8BIT:
|
||||
sampleSize = 1;
|
||||
break;
|
||||
case AudioFormat.ENCODING_PCM_16BIT:
|
||||
sampleSize = 2;
|
||||
break;
|
||||
case AudioFormat.ENCODING_PCM_FLOAT:
|
||||
sampleSize = 4;
|
||||
break;
|
||||
default:
|
||||
Log.v(TAG, "Requested format " + audioFormat + ", getting ENCODING_PCM_16BIT");
|
||||
audioFormat = AudioFormat.ENCODING_PCM_16BIT;
|
||||
sampleSize = 2;
|
||||
break;
|
||||
}
|
||||
|
||||
if (isCapture) {
|
||||
switch (desiredChannels) {
|
||||
case 1:
|
||||
channelConfig = AudioFormat.CHANNEL_IN_MONO;
|
||||
break;
|
||||
case 2:
|
||||
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
|
||||
break;
|
||||
default:
|
||||
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
|
||||
desiredChannels = 2;
|
||||
channelConfig = AudioFormat.CHANNEL_IN_STEREO;
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
switch (desiredChannels) {
|
||||
case 1:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_MONO;
|
||||
break;
|
||||
case 2:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
|
||||
break;
|
||||
case 3:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_STEREO | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
|
||||
break;
|
||||
case 4:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_QUAD;
|
||||
break;
|
||||
case 5:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_QUAD | AudioFormat.CHANNEL_OUT_FRONT_CENTER;
|
||||
break;
|
||||
case 6:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
||||
break;
|
||||
case 7:
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1 | AudioFormat.CHANNEL_OUT_BACK_CENTER;
|
||||
break;
|
||||
case 8:
|
||||
if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) {
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_7POINT1_SURROUND;
|
||||
} else {
|
||||
Log.v(TAG, "Requested " + desiredChannels + " channels, getting 5.1 surround");
|
||||
desiredChannels = 6;
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_5POINT1;
|
||||
}
|
||||
break;
|
||||
default:
|
||||
Log.v(TAG, "Requested " + desiredChannels + " channels, getting stereo");
|
||||
desiredChannels = 2;
|
||||
channelConfig = AudioFormat.CHANNEL_OUT_STEREO;
|
||||
break;
|
||||
}
|
||||
|
||||
/*
|
||||
Log.v(TAG, "Speaker configuration (and order of channels):");
|
||||
|
||||
if ((channelConfig & 0x00000004) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT");
|
||||
}
|
||||
if ((channelConfig & 0x00000008) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT");
|
||||
}
|
||||
if ((channelConfig & 0x00000010) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_FRONT_CENTER");
|
||||
}
|
||||
if ((channelConfig & 0x00000020) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_LOW_FREQUENCY");
|
||||
}
|
||||
if ((channelConfig & 0x00000040) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_BACK_LEFT");
|
||||
}
|
||||
if ((channelConfig & 0x00000080) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_BACK_RIGHT");
|
||||
}
|
||||
if ((channelConfig & 0x00000100) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_FRONT_LEFT_OF_CENTER");
|
||||
}
|
||||
if ((channelConfig & 0x00000200) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_FRONT_RIGHT_OF_CENTER");
|
||||
}
|
||||
if ((channelConfig & 0x00000400) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_BACK_CENTER");
|
||||
}
|
||||
if ((channelConfig & 0x00000800) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_SIDE_LEFT");
|
||||
}
|
||||
if ((channelConfig & 0x00001000) != 0) {
|
||||
Log.v(TAG, " CHANNEL_OUT_SIDE_RIGHT");
|
||||
}
|
||||
*/
|
||||
}
|
||||
frameSize = (sampleSize * desiredChannels);
|
||||
|
||||
// Let the user pick a larger buffer if they really want -- but ye
|
||||
// gods they probably shouldn't, the minimums are horrifyingly high
|
||||
// latency already
|
||||
int minBufferSize;
|
||||
if (isCapture) {
|
||||
minBufferSize = AudioRecord.getMinBufferSize(sampleRate, channelConfig, audioFormat);
|
||||
} else {
|
||||
minBufferSize = AudioTrack.getMinBufferSize(sampleRate, channelConfig, audioFormat);
|
||||
}
|
||||
desiredFrames = Math.max(desiredFrames, (minBufferSize + frameSize - 1) / frameSize);
|
||||
|
||||
int[] results = new int[4];
|
||||
|
||||
if (isCapture) {
|
||||
if (mAudioRecord == null) {
|
||||
mAudioRecord = new AudioRecord(MediaRecorder.AudioSource.DEFAULT, sampleRate,
|
||||
channelConfig, audioFormat, desiredFrames * frameSize);
|
||||
|
||||
// see notes about AudioTrack state in audioOpen(), above. Probably also applies here.
|
||||
if (mAudioRecord.getState() != AudioRecord.STATE_INITIALIZED) {
|
||||
Log.e(TAG, "Failed during initialization of AudioRecord");
|
||||
mAudioRecord.release();
|
||||
mAudioRecord = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
|
||||
mAudioRecord.setPreferredDevice(getOutputAudioDeviceInfo(deviceId));
|
||||
}
|
||||
|
||||
mAudioRecord.startRecording();
|
||||
}
|
||||
|
||||
results[0] = mAudioRecord.getSampleRate();
|
||||
results[1] = mAudioRecord.getAudioFormat();
|
||||
results[2] = mAudioRecord.getChannelCount();
|
||||
|
||||
} else {
|
||||
if (mAudioTrack == null) {
|
||||
mAudioTrack = new AudioTrack(AudioManager.STREAM_MUSIC, sampleRate, channelConfig, audioFormat, desiredFrames * frameSize, AudioTrack.MODE_STREAM);
|
||||
|
||||
// Instantiating AudioTrack can "succeed" without an exception and the track may still be invalid
|
||||
// Ref: https://android.googlesource.com/platform/frameworks/base/+/refs/heads/master/media/java/android/media/AudioTrack.java
|
||||
// Ref: http://developer.android.com/reference/android/media/AudioTrack.html#getState()
|
||||
if (mAudioTrack.getState() != AudioTrack.STATE_INITIALIZED) {
|
||||
/* Try again, with safer values */
|
||||
|
||||
Log.e(TAG, "Failed during initialization of Audio Track");
|
||||
mAudioTrack.release();
|
||||
mAudioTrack = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */ && deviceId != 0) {
|
||||
mAudioTrack.setPreferredDevice(getInputAudioDeviceInfo(deviceId));
|
||||
}
|
||||
|
||||
mAudioTrack.play();
|
||||
}
|
||||
|
||||
results[0] = mAudioTrack.getSampleRate();
|
||||
results[1] = mAudioTrack.getAudioFormat();
|
||||
results[2] = mAudioTrack.getChannelCount();
|
||||
}
|
||||
results[3] = desiredFrames;
|
||||
|
||||
Log.v(TAG, "Opening " + (isCapture ? "capture" : "playback") + ", got " + results[3] + " frames of " + results[2] + " channel " + getAudioFormatString(results[1]) + " audio at " + results[0] + " Hz");
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static AudioDeviceInfo getInputAudioDeviceInfo(int deviceId) {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS))
|
||||
.filter(deviceInfo -> deviceInfo.getId() == deviceId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static AudioDeviceInfo getOutputAudioDeviceInfo(int deviceId) {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS))
|
||||
.filter(deviceInfo -> deviceInfo.getId() == deviceId)
|
||||
.findFirst()
|
||||
.orElse(null);
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static void registerAudioDeviceCallback() {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
audioManager.registerAudioDeviceCallback(mAudioDeviceCallback, null);
|
||||
}
|
||||
}
|
||||
|
||||
private static void unregisterAudioDeviceCallback(Context context) {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) context.getSystemService(Context.AUDIO_SERVICE);
|
||||
audioManager.unregisterAudioDeviceCallback(mAudioDeviceCallback);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static int[] getAudioOutputDevices() {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_OUTPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
|
||||
} else {
|
||||
return NO_DEVICES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static int[] getAudioInputDevices() {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
AudioManager audioManager = (AudioManager) mContext.getSystemService(Context.AUDIO_SERVICE);
|
||||
return Arrays.stream(audioManager.getDevices(AudioManager.GET_DEVICES_INPUTS)).mapToInt(AudioDeviceInfo::getId).toArray();
|
||||
} else {
|
||||
return NO_DEVICES;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static int[] audioOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
|
||||
return open(false, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void audioWriteFloatBuffer(float[] buffer) {
|
||||
if (mAudioTrack == null) {
|
||||
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
|
||||
return;
|
||||
}
|
||||
|
||||
if (android.os.Build.VERSION.SDK_INT < 21 /* Android 5.0 (LOLLIPOP) */) {
|
||||
Log.e(TAG, "Attempted to make an incompatible audio call with uninitialized audio! (floating-point output is supported since Android 5.0 Lollipop)");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < buffer.length;) {
|
||||
int result = mAudioTrack.write(buffer, i, buffer.length - i, AudioTrack.WRITE_BLOCKING);
|
||||
if (result > 0) {
|
||||
i += result;
|
||||
} else if (result == 0) {
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch(InterruptedException e) {
|
||||
// Nom nom
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "SDL audio: error return from write(float)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void audioWriteShortBuffer(short[] buffer) {
|
||||
if (mAudioTrack == null) {
|
||||
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < buffer.length;) {
|
||||
int result = mAudioTrack.write(buffer, i, buffer.length - i);
|
||||
if (result > 0) {
|
||||
i += result;
|
||||
} else if (result == 0) {
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch(InterruptedException e) {
|
||||
// Nom nom
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "SDL audio: error return from write(short)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void audioWriteByteBuffer(byte[] buffer) {
|
||||
if (mAudioTrack == null) {
|
||||
Log.e(TAG, "Attempted to make audio call with uninitialized audio!");
|
||||
return;
|
||||
}
|
||||
|
||||
for (int i = 0; i < buffer.length; ) {
|
||||
int result = mAudioTrack.write(buffer, i, buffer.length - i);
|
||||
if (result > 0) {
|
||||
i += result;
|
||||
} else if (result == 0) {
|
||||
try {
|
||||
Thread.sleep(1);
|
||||
} catch(InterruptedException e) {
|
||||
// Nom nom
|
||||
}
|
||||
} else {
|
||||
Log.w(TAG, "SDL audio: error return from write(byte)");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static int[] captureOpen(int sampleRate, int audioFormat, int desiredChannels, int desiredFrames, int deviceId) {
|
||||
return open(true, sampleRate, audioFormat, desiredChannels, desiredFrames, deviceId);
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static int captureReadFloatBuffer(float[] buffer, boolean blocking) {
|
||||
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
|
||||
return 0;
|
||||
} else {
|
||||
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
|
||||
}
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static int captureReadShortBuffer(short[] buffer, boolean blocking) {
|
||||
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
|
||||
return mAudioRecord.read(buffer, 0, buffer.length);
|
||||
} else {
|
||||
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
|
||||
}
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static int captureReadByteBuffer(byte[] buffer, boolean blocking) {
|
||||
if (Build.VERSION.SDK_INT < 23 /* Android 6.0 (M) */) {
|
||||
return mAudioRecord.read(buffer, 0, buffer.length);
|
||||
} else {
|
||||
return mAudioRecord.read(buffer, 0, buffer.length, blocking ? AudioRecord.READ_BLOCKING : AudioRecord.READ_NON_BLOCKING);
|
||||
}
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static void audioClose() {
|
||||
if (mAudioTrack != null) {
|
||||
mAudioTrack.stop();
|
||||
mAudioTrack.release();
|
||||
mAudioTrack = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static void captureClose() {
|
||||
if (mAudioRecord != null) {
|
||||
mAudioRecord.stop();
|
||||
mAudioRecord.release();
|
||||
mAudioRecord = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** This method is called by SDL using JNI. */
|
||||
public static void audioSetThreadPriority(boolean iscapture, int device_id) {
|
||||
try {
|
||||
|
||||
/* Set thread name */
|
||||
if (iscapture) {
|
||||
Thread.currentThread().setName("SDLAudioC" + device_id);
|
||||
} else {
|
||||
Thread.currentThread().setName("SDLAudioP" + device_id);
|
||||
}
|
||||
|
||||
/* Set thread priority */
|
||||
android.os.Process.setThreadPriority(android.os.Process.THREAD_PRIORITY_AUDIO);
|
||||
|
||||
} catch (Exception e) {
|
||||
Log.v(TAG, "modify thread properties failed " + e.toString());
|
||||
}
|
||||
}
|
||||
|
||||
public static native int nativeSetupJNI();
|
||||
|
||||
public static native void removeAudioDevice(boolean isCapture, int deviceId);
|
||||
|
||||
public static native void addAudioDevice(boolean isCapture, int deviceId);
|
||||
|
||||
}
|
||||
@@ -0,0 +1,854 @@
|
||||
package org.libsdl.app;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.Comparator;
|
||||
import java.util.List;
|
||||
|
||||
import android.content.Context;
|
||||
import android.os.Build;
|
||||
import android.os.VibrationEffect;
|
||||
import android.os.Vibrator;
|
||||
import android.util.Log;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.View;
|
||||
|
||||
|
||||
public class SDLControllerManager
|
||||
{
|
||||
|
||||
public static native int nativeSetupJNI();
|
||||
|
||||
public static native int nativeAddJoystick(int device_id, String name, String desc,
|
||||
int vendor_id, int product_id,
|
||||
boolean is_accelerometer, int button_mask,
|
||||
int naxes, int axis_mask, int nhats, int nballs);
|
||||
public static native int nativeRemoveJoystick(int device_id);
|
||||
public static native int nativeAddHaptic(int device_id, String name);
|
||||
public static native int nativeRemoveHaptic(int device_id);
|
||||
public static native int onNativePadDown(int device_id, int keycode);
|
||||
public static native int onNativePadUp(int device_id, int keycode);
|
||||
public static native void onNativeJoy(int device_id, int axis,
|
||||
float value);
|
||||
public static native void onNativeHat(int device_id, int hat_id,
|
||||
int x, int y);
|
||||
|
||||
protected static SDLJoystickHandler mJoystickHandler;
|
||||
protected static SDLHapticHandler mHapticHandler;
|
||||
|
||||
private static final String TAG = "SDLControllerManager";
|
||||
|
||||
public static void initialize() {
|
||||
if (mJoystickHandler == null) {
|
||||
if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) {
|
||||
mJoystickHandler = new SDLJoystickHandler_API19();
|
||||
} else {
|
||||
mJoystickHandler = new SDLJoystickHandler_API16();
|
||||
}
|
||||
}
|
||||
|
||||
if (mHapticHandler == null) {
|
||||
if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) {
|
||||
mHapticHandler = new SDLHapticHandler_API26();
|
||||
} else {
|
||||
mHapticHandler = new SDLHapticHandler();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Joystick glue code, just a series of stubs that redirect to the SDLJoystickHandler instance
|
||||
public static boolean handleJoystickMotionEvent(MotionEvent event) {
|
||||
return mJoystickHandler.handleMotionEvent(event);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void pollInputDevices() {
|
||||
mJoystickHandler.pollInputDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void pollHapticDevices() {
|
||||
mHapticHandler.pollHapticDevices();
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void hapticRun(int device_id, float intensity, int length) {
|
||||
mHapticHandler.run(device_id, intensity, length);
|
||||
}
|
||||
|
||||
/**
|
||||
* This method is called by SDL using JNI.
|
||||
*/
|
||||
public static void hapticStop(int device_id)
|
||||
{
|
||||
mHapticHandler.stop(device_id);
|
||||
}
|
||||
|
||||
// Check if a given device is considered a possible SDL joystick
|
||||
public static boolean isDeviceSDLJoystick(int deviceId) {
|
||||
InputDevice device = InputDevice.getDevice(deviceId);
|
||||
// We cannot use InputDevice.isVirtual before API 16, so let's accept
|
||||
// only nonnegative device ids (VIRTUAL_KEYBOARD equals -1)
|
||||
if ((device == null) || (deviceId < 0)) {
|
||||
return false;
|
||||
}
|
||||
int sources = device.getSources();
|
||||
|
||||
/* This is called for every button press, so let's not spam the logs */
|
||||
/*
|
||||
if ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
|
||||
Log.v(TAG, "Input device " + device.getName() + " has class joystick.");
|
||||
}
|
||||
if ((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) {
|
||||
Log.v(TAG, "Input device " + device.getName() + " is a dpad.");
|
||||
}
|
||||
if ((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD) {
|
||||
Log.v(TAG, "Input device " + device.getName() + " is a gamepad.");
|
||||
}
|
||||
*/
|
||||
|
||||
return ((sources & InputDevice.SOURCE_CLASS_JOYSTICK) != 0 ||
|
||||
((sources & InputDevice.SOURCE_DPAD) == InputDevice.SOURCE_DPAD) ||
|
||||
((sources & InputDevice.SOURCE_GAMEPAD) == InputDevice.SOURCE_GAMEPAD)
|
||||
);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SDLJoystickHandler {
|
||||
|
||||
/**
|
||||
* Handles given MotionEvent.
|
||||
* @param event the event to be handled.
|
||||
* @return if given event was processed.
|
||||
*/
|
||||
public boolean handleMotionEvent(MotionEvent event) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles adding and removing of input devices.
|
||||
*/
|
||||
public void pollInputDevices() {
|
||||
}
|
||||
}
|
||||
|
||||
/* Actual joystick functionality available for API >= 12 devices */
|
||||
class SDLJoystickHandler_API16 extends SDLJoystickHandler {
|
||||
|
||||
static class SDLJoystick {
|
||||
public int device_id;
|
||||
public String name;
|
||||
public String desc;
|
||||
public ArrayList<InputDevice.MotionRange> axes;
|
||||
public ArrayList<InputDevice.MotionRange> hats;
|
||||
}
|
||||
static class RangeComparator implements Comparator<InputDevice.MotionRange> {
|
||||
@Override
|
||||
public int compare(InputDevice.MotionRange arg0, InputDevice.MotionRange arg1) {
|
||||
// Some controllers, like the Moga Pro 2, return AXIS_GAS (22) for right trigger and AXIS_BRAKE (23) for left trigger - swap them so they're sorted in the right order for SDL
|
||||
int arg0Axis = arg0.getAxis();
|
||||
int arg1Axis = arg1.getAxis();
|
||||
if (arg0Axis == MotionEvent.AXIS_GAS) {
|
||||
arg0Axis = MotionEvent.AXIS_BRAKE;
|
||||
} else if (arg0Axis == MotionEvent.AXIS_BRAKE) {
|
||||
arg0Axis = MotionEvent.AXIS_GAS;
|
||||
}
|
||||
if (arg1Axis == MotionEvent.AXIS_GAS) {
|
||||
arg1Axis = MotionEvent.AXIS_BRAKE;
|
||||
} else if (arg1Axis == MotionEvent.AXIS_BRAKE) {
|
||||
arg1Axis = MotionEvent.AXIS_GAS;
|
||||
}
|
||||
|
||||
// Make sure the AXIS_Z is sorted between AXIS_RY and AXIS_RZ.
|
||||
// This is because the usual pairing are:
|
||||
// - AXIS_X + AXIS_Y (left stick).
|
||||
// - AXIS_RX, AXIS_RY (sometimes the right stick, sometimes triggers).
|
||||
// - AXIS_Z, AXIS_RZ (sometimes the right stick, sometimes triggers).
|
||||
// This sorts the axes in the above order, which tends to be correct
|
||||
// for Xbox-ish game pads that have the right stick on RX/RY and the
|
||||
// triggers on Z/RZ.
|
||||
//
|
||||
// Gamepads that don't have AXIS_Z/AXIS_RZ but use
|
||||
// AXIS_LTRIGGER/AXIS_RTRIGGER are unaffected by this.
|
||||
//
|
||||
// References:
|
||||
// - https://developer.android.com/develop/ui/views/touch-and-input/game-controllers/controller-input
|
||||
// - https://www.kernel.org/doc/html/latest/input/gamepad.html
|
||||
if (arg0Axis == MotionEvent.AXIS_Z) {
|
||||
arg0Axis = MotionEvent.AXIS_RZ - 1;
|
||||
} else if (arg0Axis > MotionEvent.AXIS_Z && arg0Axis < MotionEvent.AXIS_RZ) {
|
||||
--arg0Axis;
|
||||
}
|
||||
if (arg1Axis == MotionEvent.AXIS_Z) {
|
||||
arg1Axis = MotionEvent.AXIS_RZ - 1;
|
||||
} else if (arg1Axis > MotionEvent.AXIS_Z && arg1Axis < MotionEvent.AXIS_RZ) {
|
||||
--arg1Axis;
|
||||
}
|
||||
|
||||
return arg0Axis - arg1Axis;
|
||||
}
|
||||
}
|
||||
|
||||
private final ArrayList<SDLJoystick> mJoysticks;
|
||||
|
||||
public SDLJoystickHandler_API16() {
|
||||
|
||||
mJoysticks = new ArrayList<SDLJoystick>();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void pollInputDevices() {
|
||||
int[] deviceIds = InputDevice.getDeviceIds();
|
||||
|
||||
for (int device_id : deviceIds) {
|
||||
if (SDLControllerManager.isDeviceSDLJoystick(device_id)) {
|
||||
SDLJoystick joystick = getJoystick(device_id);
|
||||
if (joystick == null) {
|
||||
InputDevice joystickDevice = InputDevice.getDevice(device_id);
|
||||
joystick = new SDLJoystick();
|
||||
joystick.device_id = device_id;
|
||||
joystick.name = joystickDevice.getName();
|
||||
joystick.desc = getJoystickDescriptor(joystickDevice);
|
||||
joystick.axes = new ArrayList<InputDevice.MotionRange>();
|
||||
joystick.hats = new ArrayList<InputDevice.MotionRange>();
|
||||
|
||||
List<InputDevice.MotionRange> ranges = joystickDevice.getMotionRanges();
|
||||
Collections.sort(ranges, new RangeComparator());
|
||||
for (InputDevice.MotionRange range : ranges) {
|
||||
if ((range.getSource() & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) {
|
||||
if (range.getAxis() == MotionEvent.AXIS_HAT_X || range.getAxis() == MotionEvent.AXIS_HAT_Y) {
|
||||
joystick.hats.add(range);
|
||||
} else {
|
||||
joystick.axes.add(range);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mJoysticks.add(joystick);
|
||||
SDLControllerManager.nativeAddJoystick(joystick.device_id, joystick.name, joystick.desc,
|
||||
getVendorId(joystickDevice), getProductId(joystickDevice), false,
|
||||
getButtonMask(joystickDevice), joystick.axes.size(), getAxisMask(joystick.axes), joystick.hats.size()/2, 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Check removed devices */
|
||||
ArrayList<Integer> removedDevices = null;
|
||||
for (SDLJoystick joystick : mJoysticks) {
|
||||
int device_id = joystick.device_id;
|
||||
int i;
|
||||
for (i = 0; i < deviceIds.length; i++) {
|
||||
if (device_id == deviceIds[i]) break;
|
||||
}
|
||||
if (i == deviceIds.length) {
|
||||
if (removedDevices == null) {
|
||||
removedDevices = new ArrayList<Integer>();
|
||||
}
|
||||
removedDevices.add(device_id);
|
||||
}
|
||||
}
|
||||
|
||||
if (removedDevices != null) {
|
||||
for (int device_id : removedDevices) {
|
||||
SDLControllerManager.nativeRemoveJoystick(device_id);
|
||||
for (int i = 0; i < mJoysticks.size(); i++) {
|
||||
if (mJoysticks.get(i).device_id == device_id) {
|
||||
mJoysticks.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected SDLJoystick getJoystick(int device_id) {
|
||||
for (SDLJoystick joystick : mJoysticks) {
|
||||
if (joystick.device_id == device_id) {
|
||||
return joystick;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean handleMotionEvent(MotionEvent event) {
|
||||
int actionPointerIndex = event.getActionIndex();
|
||||
int action = event.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_MOVE) {
|
||||
SDLJoystick joystick = getJoystick(event.getDeviceId());
|
||||
if (joystick != null) {
|
||||
for (int i = 0; i < joystick.axes.size(); i++) {
|
||||
InputDevice.MotionRange range = joystick.axes.get(i);
|
||||
/* Normalize the value to -1...1 */
|
||||
float value = (event.getAxisValue(range.getAxis(), actionPointerIndex) - range.getMin()) / range.getRange() * 2.0f - 1.0f;
|
||||
SDLControllerManager.onNativeJoy(joystick.device_id, i, value);
|
||||
}
|
||||
for (int i = 0; i < joystick.hats.size() / 2; i++) {
|
||||
int hatX = Math.round(event.getAxisValue(joystick.hats.get(2 * i).getAxis(), actionPointerIndex));
|
||||
int hatY = Math.round(event.getAxisValue(joystick.hats.get(2 * i + 1).getAxis(), actionPointerIndex));
|
||||
SDLControllerManager.onNativeHat(joystick.device_id, i, hatX, hatY);
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public String getJoystickDescriptor(InputDevice joystickDevice) {
|
||||
String desc = joystickDevice.getDescriptor();
|
||||
|
||||
if (desc != null && !desc.isEmpty()) {
|
||||
return desc;
|
||||
}
|
||||
|
||||
return joystickDevice.getName();
|
||||
}
|
||||
public int getProductId(InputDevice joystickDevice) {
|
||||
return 0;
|
||||
}
|
||||
public int getVendorId(InputDevice joystickDevice) {
|
||||
return 0;
|
||||
}
|
||||
public int getAxisMask(List<InputDevice.MotionRange> ranges) {
|
||||
return -1;
|
||||
}
|
||||
public int getButtonMask(InputDevice joystickDevice) {
|
||||
return -1;
|
||||
}
|
||||
}
|
||||
|
||||
class SDLJoystickHandler_API19 extends SDLJoystickHandler_API16 {
|
||||
|
||||
@Override
|
||||
public int getProductId(InputDevice joystickDevice) {
|
||||
return joystickDevice.getProductId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getVendorId(InputDevice joystickDevice) {
|
||||
return joystickDevice.getVendorId();
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getAxisMask(List<InputDevice.MotionRange> ranges) {
|
||||
// For compatibility, keep computing the axis mask like before,
|
||||
// only really distinguishing 2, 4 and 6 axes.
|
||||
int axis_mask = 0;
|
||||
if (ranges.size() >= 2) {
|
||||
// ((1 << SDL_GAMEPAD_AXIS_LEFTX) | (1 << SDL_GAMEPAD_AXIS_LEFTY))
|
||||
axis_mask |= 0x0003;
|
||||
}
|
||||
if (ranges.size() >= 4) {
|
||||
// ((1 << SDL_GAMEPAD_AXIS_RIGHTX) | (1 << SDL_GAMEPAD_AXIS_RIGHTY))
|
||||
axis_mask |= 0x000c;
|
||||
}
|
||||
if (ranges.size() >= 6) {
|
||||
// ((1 << SDL_GAMEPAD_AXIS_LEFT_TRIGGER) | (1 << SDL_GAMEPAD_AXIS_RIGHT_TRIGGER))
|
||||
axis_mask |= 0x0030;
|
||||
}
|
||||
// Also add an indicator bit for whether the sorting order has changed.
|
||||
// This serves to disable outdated gamecontrollerdb.txt mappings.
|
||||
boolean have_z = false;
|
||||
boolean have_past_z_before_rz = false;
|
||||
for (InputDevice.MotionRange range : ranges) {
|
||||
int axis = range.getAxis();
|
||||
if (axis == MotionEvent.AXIS_Z) {
|
||||
have_z = true;
|
||||
} else if (axis > MotionEvent.AXIS_Z && axis < MotionEvent.AXIS_RZ) {
|
||||
have_past_z_before_rz = true;
|
||||
}
|
||||
}
|
||||
if (have_z && have_past_z_before_rz) {
|
||||
// If both these exist, the compare() function changed sorting order.
|
||||
// Set a bit to indicate this fact.
|
||||
axis_mask |= 0x8000;
|
||||
}
|
||||
return axis_mask;
|
||||
}
|
||||
|
||||
@Override
|
||||
public int getButtonMask(InputDevice joystickDevice) {
|
||||
int button_mask = 0;
|
||||
int[] keys = new int[] {
|
||||
KeyEvent.KEYCODE_BUTTON_A,
|
||||
KeyEvent.KEYCODE_BUTTON_B,
|
||||
KeyEvent.KEYCODE_BUTTON_X,
|
||||
KeyEvent.KEYCODE_BUTTON_Y,
|
||||
KeyEvent.KEYCODE_BACK,
|
||||
KeyEvent.KEYCODE_MENU,
|
||||
KeyEvent.KEYCODE_BUTTON_MODE,
|
||||
KeyEvent.KEYCODE_BUTTON_START,
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBL,
|
||||
KeyEvent.KEYCODE_BUTTON_THUMBR,
|
||||
KeyEvent.KEYCODE_BUTTON_L1,
|
||||
KeyEvent.KEYCODE_BUTTON_R1,
|
||||
KeyEvent.KEYCODE_DPAD_UP,
|
||||
KeyEvent.KEYCODE_DPAD_DOWN,
|
||||
KeyEvent.KEYCODE_DPAD_LEFT,
|
||||
KeyEvent.KEYCODE_DPAD_RIGHT,
|
||||
KeyEvent.KEYCODE_BUTTON_SELECT,
|
||||
KeyEvent.KEYCODE_DPAD_CENTER,
|
||||
|
||||
// These don't map into any SDL controller buttons directly
|
||||
KeyEvent.KEYCODE_BUTTON_L2,
|
||||
KeyEvent.KEYCODE_BUTTON_R2,
|
||||
KeyEvent.KEYCODE_BUTTON_C,
|
||||
KeyEvent.KEYCODE_BUTTON_Z,
|
||||
KeyEvent.KEYCODE_BUTTON_1,
|
||||
KeyEvent.KEYCODE_BUTTON_2,
|
||||
KeyEvent.KEYCODE_BUTTON_3,
|
||||
KeyEvent.KEYCODE_BUTTON_4,
|
||||
KeyEvent.KEYCODE_BUTTON_5,
|
||||
KeyEvent.KEYCODE_BUTTON_6,
|
||||
KeyEvent.KEYCODE_BUTTON_7,
|
||||
KeyEvent.KEYCODE_BUTTON_8,
|
||||
KeyEvent.KEYCODE_BUTTON_9,
|
||||
KeyEvent.KEYCODE_BUTTON_10,
|
||||
KeyEvent.KEYCODE_BUTTON_11,
|
||||
KeyEvent.KEYCODE_BUTTON_12,
|
||||
KeyEvent.KEYCODE_BUTTON_13,
|
||||
KeyEvent.KEYCODE_BUTTON_14,
|
||||
KeyEvent.KEYCODE_BUTTON_15,
|
||||
KeyEvent.KEYCODE_BUTTON_16,
|
||||
};
|
||||
int[] masks = new int[] {
|
||||
(1 << 0), // A -> A
|
||||
(1 << 1), // B -> B
|
||||
(1 << 2), // X -> X
|
||||
(1 << 3), // Y -> Y
|
||||
(1 << 4), // BACK -> BACK
|
||||
(1 << 6), // MENU -> START
|
||||
(1 << 5), // MODE -> GUIDE
|
||||
(1 << 6), // START -> START
|
||||
(1 << 7), // THUMBL -> LEFTSTICK
|
||||
(1 << 8), // THUMBR -> RIGHTSTICK
|
||||
(1 << 9), // L1 -> LEFTSHOULDER
|
||||
(1 << 10), // R1 -> RIGHTSHOULDER
|
||||
(1 << 11), // DPAD_UP -> DPAD_UP
|
||||
(1 << 12), // DPAD_DOWN -> DPAD_DOWN
|
||||
(1 << 13), // DPAD_LEFT -> DPAD_LEFT
|
||||
(1 << 14), // DPAD_RIGHT -> DPAD_RIGHT
|
||||
(1 << 4), // SELECT -> BACK
|
||||
(1 << 0), // DPAD_CENTER -> A
|
||||
(1 << 15), // L2 -> ??
|
||||
(1 << 16), // R2 -> ??
|
||||
(1 << 17), // C -> ??
|
||||
(1 << 18), // Z -> ??
|
||||
(1 << 20), // 1 -> ??
|
||||
(1 << 21), // 2 -> ??
|
||||
(1 << 22), // 3 -> ??
|
||||
(1 << 23), // 4 -> ??
|
||||
(1 << 24), // 5 -> ??
|
||||
(1 << 25), // 6 -> ??
|
||||
(1 << 26), // 7 -> ??
|
||||
(1 << 27), // 8 -> ??
|
||||
(1 << 28), // 9 -> ??
|
||||
(1 << 29), // 10 -> ??
|
||||
(1 << 30), // 11 -> ??
|
||||
(1 << 31), // 12 -> ??
|
||||
// We're out of room...
|
||||
0xFFFFFFFF, // 13 -> ??
|
||||
0xFFFFFFFF, // 14 -> ??
|
||||
0xFFFFFFFF, // 15 -> ??
|
||||
0xFFFFFFFF, // 16 -> ??
|
||||
};
|
||||
boolean[] has_keys = joystickDevice.hasKeys(keys);
|
||||
for (int i = 0; i < keys.length; ++i) {
|
||||
if (has_keys[i]) {
|
||||
button_mask |= masks[i];
|
||||
}
|
||||
}
|
||||
return button_mask;
|
||||
}
|
||||
}
|
||||
|
||||
class SDLHapticHandler_API26 extends SDLHapticHandler {
|
||||
@Override
|
||||
public void run(int device_id, float intensity, int length) {
|
||||
SDLHaptic haptic = getHaptic(device_id);
|
||||
if (haptic != null) {
|
||||
Log.d("SDL", "Rtest: Vibe with intensity " + intensity + " for " + length);
|
||||
if (intensity == 0.0f) {
|
||||
stop(device_id);
|
||||
return;
|
||||
}
|
||||
|
||||
int vibeValue = Math.round(intensity * 255);
|
||||
|
||||
if (vibeValue > 255) {
|
||||
vibeValue = 255;
|
||||
}
|
||||
if (vibeValue < 1) {
|
||||
stop(device_id);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
haptic.vib.vibrate(VibrationEffect.createOneShot(length, vibeValue));
|
||||
}
|
||||
catch (Exception e) {
|
||||
// Fall back to the generic method, which uses DEFAULT_AMPLITUDE, but works even if
|
||||
// something went horribly wrong with the Android 8.0 APIs.
|
||||
haptic.vib.vibrate(length);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SDLHapticHandler {
|
||||
|
||||
static class SDLHaptic {
|
||||
public int device_id;
|
||||
public String name;
|
||||
public Vibrator vib;
|
||||
}
|
||||
|
||||
private final ArrayList<SDLHaptic> mHaptics;
|
||||
|
||||
public SDLHapticHandler() {
|
||||
mHaptics = new ArrayList<SDLHaptic>();
|
||||
}
|
||||
|
||||
public void run(int device_id, float intensity, int length) {
|
||||
SDLHaptic haptic = getHaptic(device_id);
|
||||
if (haptic != null) {
|
||||
haptic.vib.vibrate(length);
|
||||
}
|
||||
}
|
||||
|
||||
public void stop(int device_id) {
|
||||
SDLHaptic haptic = getHaptic(device_id);
|
||||
if (haptic != null) {
|
||||
haptic.vib.cancel();
|
||||
}
|
||||
}
|
||||
|
||||
public void pollHapticDevices() {
|
||||
|
||||
final int deviceId_VIBRATOR_SERVICE = 999999;
|
||||
boolean hasVibratorService = false;
|
||||
|
||||
int[] deviceIds = InputDevice.getDeviceIds();
|
||||
// It helps processing the device ids in reverse order
|
||||
// For example, in the case of the XBox 360 wireless dongle,
|
||||
// so the first controller seen by SDL matches what the receiver
|
||||
// considers to be the first controller
|
||||
|
||||
for (int i = deviceIds.length - 1; i > -1; i--) {
|
||||
SDLHaptic haptic = getHaptic(deviceIds[i]);
|
||||
if (haptic == null) {
|
||||
InputDevice device = InputDevice.getDevice(deviceIds[i]);
|
||||
Vibrator vib = device.getVibrator();
|
||||
if (vib.hasVibrator()) {
|
||||
haptic = new SDLHaptic();
|
||||
haptic.device_id = deviceIds[i];
|
||||
haptic.name = device.getName();
|
||||
haptic.vib = vib;
|
||||
mHaptics.add(haptic);
|
||||
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Check VIBRATOR_SERVICE */
|
||||
Vibrator vib = (Vibrator) SDL.getContext().getSystemService(Context.VIBRATOR_SERVICE);
|
||||
if (vib != null) {
|
||||
hasVibratorService = vib.hasVibrator();
|
||||
|
||||
if (hasVibratorService) {
|
||||
SDLHaptic haptic = getHaptic(deviceId_VIBRATOR_SERVICE);
|
||||
if (haptic == null) {
|
||||
haptic = new SDLHaptic();
|
||||
haptic.device_id = deviceId_VIBRATOR_SERVICE;
|
||||
haptic.name = "VIBRATOR_SERVICE";
|
||||
haptic.vib = vib;
|
||||
mHaptics.add(haptic);
|
||||
SDLControllerManager.nativeAddHaptic(haptic.device_id, haptic.name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* Check removed devices */
|
||||
ArrayList<Integer> removedDevices = null;
|
||||
for (SDLHaptic haptic : mHaptics) {
|
||||
int device_id = haptic.device_id;
|
||||
int i;
|
||||
for (i = 0; i < deviceIds.length; i++) {
|
||||
if (device_id == deviceIds[i]) break;
|
||||
}
|
||||
|
||||
if (device_id != deviceId_VIBRATOR_SERVICE || !hasVibratorService) {
|
||||
if (i == deviceIds.length) {
|
||||
if (removedDevices == null) {
|
||||
removedDevices = new ArrayList<Integer>();
|
||||
}
|
||||
removedDevices.add(device_id);
|
||||
}
|
||||
} // else: don't remove the vibrator if it is still present
|
||||
}
|
||||
|
||||
if (removedDevices != null) {
|
||||
for (int device_id : removedDevices) {
|
||||
SDLControllerManager.nativeRemoveHaptic(device_id);
|
||||
for (int i = 0; i < mHaptics.size(); i++) {
|
||||
if (mHaptics.get(i).device_id == device_id) {
|
||||
mHaptics.remove(i);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected SDLHaptic getHaptic(int device_id) {
|
||||
for (SDLHaptic haptic : mHaptics) {
|
||||
if (haptic.device_id == device_id) {
|
||||
return haptic;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class SDLGenericMotionListener_API12 implements View.OnGenericMotionListener {
|
||||
// Generic Motion (mouse hover, joystick...) events go here
|
||||
@Override
|
||||
public boolean onGenericMotion(View v, MotionEvent event) {
|
||||
float x, y;
|
||||
int action;
|
||||
|
||||
switch ( event.getSource() ) {
|
||||
case InputDevice.SOURCE_JOYSTICK:
|
||||
return SDLControllerManager.handleJoystickMotionEvent(event);
|
||||
|
||||
case InputDevice.SOURCE_MOUSE:
|
||||
action = event.getActionMasked();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_SCROLL:
|
||||
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
|
||||
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_HOVER_MOVE:
|
||||
x = event.getX(0);
|
||||
y = event.getY(0);
|
||||
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Event was not managed
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean supportsRelativeMouse() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean inRelativeMode() {
|
||||
return false;
|
||||
}
|
||||
|
||||
public boolean setRelativeMouseEnabled(boolean enabled) {
|
||||
return false;
|
||||
}
|
||||
|
||||
public void reclaimRelativeMouseModeIfNeeded()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
public float getEventX(MotionEvent event) {
|
||||
return event.getX(0);
|
||||
}
|
||||
|
||||
public float getEventY(MotionEvent event) {
|
||||
return event.getY(0);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
class SDLGenericMotionListener_API24 extends SDLGenericMotionListener_API12 {
|
||||
// Generic Motion (mouse hover, joystick...) events go here
|
||||
|
||||
private boolean mRelativeModeEnabled;
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotion(View v, MotionEvent event) {
|
||||
|
||||
// Handle relative mouse mode
|
||||
if (mRelativeModeEnabled) {
|
||||
if (event.getSource() == InputDevice.SOURCE_MOUSE) {
|
||||
int action = event.getActionMasked();
|
||||
if (action == MotionEvent.ACTION_HOVER_MOVE) {
|
||||
float x = event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
|
||||
float y = event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, true);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Event was not managed, call SDLGenericMotionListener_API12 method
|
||||
return super.onGenericMotion(v, event);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRelativeMouse() {
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean inRelativeMode() {
|
||||
return mRelativeModeEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setRelativeMouseEnabled(boolean enabled) {
|
||||
mRelativeModeEnabled = enabled;
|
||||
return true;
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEventX(MotionEvent event) {
|
||||
if (mRelativeModeEnabled) {
|
||||
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_X);
|
||||
} else {
|
||||
return event.getX(0);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEventY(MotionEvent event) {
|
||||
if (mRelativeModeEnabled) {
|
||||
return event.getAxisValue(MotionEvent.AXIS_RELATIVE_Y);
|
||||
} else {
|
||||
return event.getY(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class SDLGenericMotionListener_API26 extends SDLGenericMotionListener_API24 {
|
||||
// Generic Motion (mouse hover, joystick...) events go here
|
||||
private boolean mRelativeModeEnabled;
|
||||
|
||||
@Override
|
||||
public boolean onGenericMotion(View v, MotionEvent event) {
|
||||
float x, y;
|
||||
int action;
|
||||
|
||||
switch ( event.getSource() ) {
|
||||
case InputDevice.SOURCE_JOYSTICK:
|
||||
return SDLControllerManager.handleJoystickMotionEvent(event);
|
||||
|
||||
case InputDevice.SOURCE_MOUSE:
|
||||
// DeX desktop mouse cursor is a separate non-standard input type.
|
||||
case InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN:
|
||||
action = event.getActionMasked();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_SCROLL:
|
||||
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
|
||||
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_HOVER_MOVE:
|
||||
x = event.getX(0);
|
||||
y = event.getY(0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
case InputDevice.SOURCE_MOUSE_RELATIVE:
|
||||
action = event.getActionMasked();
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_SCROLL:
|
||||
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
|
||||
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_HOVER_MOVE:
|
||||
x = event.getX(0);
|
||||
y = event.getY(0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, true);
|
||||
return true;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
// Event was not managed
|
||||
return false;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean supportsRelativeMouse() {
|
||||
return (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */);
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean inRelativeMode() {
|
||||
return mRelativeModeEnabled;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean setRelativeMouseEnabled(boolean enabled) {
|
||||
if (!SDLActivity.isDeXMode() || Build.VERSION.SDK_INT >= 27 /* Android 8.1 (O_MR1) */) {
|
||||
if (enabled) {
|
||||
SDLActivity.getContentView().requestPointerCapture();
|
||||
} else {
|
||||
SDLActivity.getContentView().releasePointerCapture();
|
||||
}
|
||||
mRelativeModeEnabled = enabled;
|
||||
return true;
|
||||
} else {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void reclaimRelativeMouseModeIfNeeded()
|
||||
{
|
||||
if (mRelativeModeEnabled && !SDLActivity.isDeXMode()) {
|
||||
SDLActivity.getContentView().requestPointerCapture();
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEventX(MotionEvent event) {
|
||||
// Relative mouse in capture mode will only have relative for X/Y
|
||||
return event.getX(0);
|
||||
}
|
||||
|
||||
@Override
|
||||
public float getEventY(MotionEvent event) {
|
||||
// Relative mouse in capture mode will only have relative for X/Y
|
||||
return event.getY(0);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,405 @@
|
||||
package org.libsdl.app;
|
||||
|
||||
|
||||
import android.content.Context;
|
||||
import android.content.pm.ActivityInfo;
|
||||
import android.hardware.Sensor;
|
||||
import android.hardware.SensorEvent;
|
||||
import android.hardware.SensorEventListener;
|
||||
import android.hardware.SensorManager;
|
||||
import android.os.Build;
|
||||
import android.util.DisplayMetrics;
|
||||
import android.util.Log;
|
||||
import android.view.Display;
|
||||
import android.view.InputDevice;
|
||||
import android.view.KeyEvent;
|
||||
import android.view.MotionEvent;
|
||||
import android.view.Surface;
|
||||
import android.view.SurfaceHolder;
|
||||
import android.view.SurfaceView;
|
||||
import android.view.View;
|
||||
import android.view.WindowManager;
|
||||
|
||||
|
||||
/**
|
||||
SDLSurface. This is what we draw on, so we need to know when it's created
|
||||
in order to do anything useful.
|
||||
|
||||
Because of this, that's where we set up the SDL thread
|
||||
*/
|
||||
public class SDLSurface extends SurfaceView implements SurfaceHolder.Callback,
|
||||
View.OnKeyListener, View.OnTouchListener, SensorEventListener {
|
||||
|
||||
// Sensors
|
||||
protected SensorManager mSensorManager;
|
||||
protected Display mDisplay;
|
||||
|
||||
// Keep track of the surface size to normalize touch events
|
||||
protected float mWidth, mHeight;
|
||||
|
||||
// Is SurfaceView ready for rendering
|
||||
public boolean mIsSurfaceReady;
|
||||
|
||||
// Startup
|
||||
public SDLSurface(Context context) {
|
||||
super(context);
|
||||
getHolder().addCallback(this);
|
||||
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
requestFocus();
|
||||
setOnKeyListener(this);
|
||||
setOnTouchListener(this);
|
||||
|
||||
mDisplay = ((WindowManager)context.getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay();
|
||||
mSensorManager = (SensorManager)context.getSystemService(Context.SENSOR_SERVICE);
|
||||
|
||||
setOnGenericMotionListener(SDLActivity.getMotionListener());
|
||||
|
||||
// Some arbitrary defaults to avoid a potential division by zero
|
||||
mWidth = 1.0f;
|
||||
mHeight = 1.0f;
|
||||
|
||||
mIsSurfaceReady = false;
|
||||
}
|
||||
|
||||
public void handlePause() {
|
||||
enableSensor(Sensor.TYPE_ACCELEROMETER, false);
|
||||
}
|
||||
|
||||
public void handleResume() {
|
||||
setFocusable(true);
|
||||
setFocusableInTouchMode(true);
|
||||
requestFocus();
|
||||
setOnKeyListener(this);
|
||||
setOnTouchListener(this);
|
||||
enableSensor(Sensor.TYPE_ACCELEROMETER, true);
|
||||
}
|
||||
|
||||
public Surface getNativeSurface() {
|
||||
return getHolder().getSurface();
|
||||
}
|
||||
|
||||
// Called when we have a valid drawing surface
|
||||
@Override
|
||||
public void surfaceCreated(SurfaceHolder holder) {
|
||||
Log.v("SDL", "surfaceCreated()");
|
||||
SDLActivity.onNativeSurfaceCreated();
|
||||
}
|
||||
|
||||
// Called when we lose the surface
|
||||
@Override
|
||||
public void surfaceDestroyed(SurfaceHolder holder) {
|
||||
Log.v("SDL", "surfaceDestroyed()");
|
||||
|
||||
// Transition to pause, if needed
|
||||
SDLActivity.mNextNativeState = SDLActivity.NativeState.PAUSED;
|
||||
SDLActivity.handleNativeState();
|
||||
|
||||
mIsSurfaceReady = false;
|
||||
SDLActivity.onNativeSurfaceDestroyed();
|
||||
}
|
||||
|
||||
// Called when the surface is resized
|
||||
@Override
|
||||
public void surfaceChanged(SurfaceHolder holder,
|
||||
int format, int width, int height) {
|
||||
Log.v("SDL", "surfaceChanged()");
|
||||
|
||||
if (SDLActivity.mSingleton == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
mWidth = width;
|
||||
mHeight = height;
|
||||
int nDeviceWidth = width;
|
||||
int nDeviceHeight = height;
|
||||
try
|
||||
{
|
||||
if (Build.VERSION.SDK_INT >= 17 /* Android 4.2 (JELLY_BEAN_MR1) */) {
|
||||
DisplayMetrics realMetrics = new DisplayMetrics();
|
||||
mDisplay.getRealMetrics( realMetrics );
|
||||
nDeviceWidth = realMetrics.widthPixels;
|
||||
nDeviceHeight = realMetrics.heightPixels;
|
||||
}
|
||||
} catch(Exception ignored) {
|
||||
}
|
||||
|
||||
synchronized(SDLActivity.getContext()) {
|
||||
// In case we're waiting on a size change after going fullscreen, send a notification.
|
||||
SDLActivity.getContext().notifyAll();
|
||||
}
|
||||
|
||||
Log.v("SDL", "Window size: " + width + "x" + height);
|
||||
Log.v("SDL", "Device size: " + nDeviceWidth + "x" + nDeviceHeight);
|
||||
SDLActivity.nativeSetScreenResolution(width, height, nDeviceWidth, nDeviceHeight, mDisplay.getRefreshRate());
|
||||
SDLActivity.onNativeResize();
|
||||
|
||||
// Prevent a screen distortion glitch,
|
||||
// for instance when the device is in Landscape and a Portrait App is resumed.
|
||||
boolean skip = false;
|
||||
int requestedOrientation = SDLActivity.mSingleton.getRequestedOrientation();
|
||||
|
||||
if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_PORTRAIT || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT) {
|
||||
if (mWidth > mHeight) {
|
||||
skip = true;
|
||||
}
|
||||
} else if (requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE || requestedOrientation == ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE) {
|
||||
if (mWidth < mHeight) {
|
||||
skip = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Special Patch for Square Resolution: Black Berry Passport
|
||||
if (skip) {
|
||||
double min = Math.min(mWidth, mHeight);
|
||||
double max = Math.max(mWidth, mHeight);
|
||||
|
||||
if (max / min < 1.20) {
|
||||
Log.v("SDL", "Don't skip on such aspect-ratio. Could be a square resolution.");
|
||||
skip = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Don't skip in MultiWindow.
|
||||
if (skip) {
|
||||
if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) {
|
||||
if (SDLActivity.mSingleton.isInMultiWindowMode()) {
|
||||
Log.v("SDL", "Don't skip in Multi-Window");
|
||||
skip = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (skip) {
|
||||
Log.v("SDL", "Skip .. Surface is not ready.");
|
||||
mIsSurfaceReady = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* If the surface has been previously destroyed by onNativeSurfaceDestroyed, recreate it here */
|
||||
SDLActivity.onNativeSurfaceChanged();
|
||||
|
||||
/* Surface is ready */
|
||||
mIsSurfaceReady = true;
|
||||
|
||||
SDLActivity.mNextNativeState = SDLActivity.NativeState.RESUMED;
|
||||
SDLActivity.handleNativeState();
|
||||
}
|
||||
|
||||
// Key events
|
||||
@Override
|
||||
public boolean onKey(View v, int keyCode, KeyEvent event) {
|
||||
return SDLActivity.handleKeyEvent(v, keyCode, event, null);
|
||||
}
|
||||
|
||||
// Touch events
|
||||
@Override
|
||||
public boolean onTouch(View v, MotionEvent event) {
|
||||
/* Ref: http://developer.android.com/training/gestures/multi.html */
|
||||
int touchDevId = event.getDeviceId();
|
||||
final int pointerCount = event.getPointerCount();
|
||||
int action = event.getActionMasked();
|
||||
int pointerFingerId;
|
||||
int i = -1;
|
||||
float x,y,p;
|
||||
|
||||
/*
|
||||
* 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;
|
||||
}
|
||||
|
||||
// 12290 = Samsung DeX mode desktop mouse
|
||||
// 12290 = 0x3002 = 0x2002 | 0x1002 = SOURCE_MOUSE | SOURCE_TOUCHSCREEN
|
||||
// 0x2 = SOURCE_CLASS_POINTER
|
||||
if (event.getSource() == InputDevice.SOURCE_MOUSE || event.getSource() == (InputDevice.SOURCE_MOUSE | InputDevice.SOURCE_TOUCHSCREEN)) {
|
||||
int mouseButton = 1;
|
||||
try {
|
||||
Object object = event.getClass().getMethod("getButtonState").invoke(event);
|
||||
if (object != null) {
|
||||
mouseButton = (Integer) object;
|
||||
}
|
||||
} catch(Exception ignored) {
|
||||
}
|
||||
|
||||
// We need to check if we're in relative mouse mode and get the axis offset rather than the x/y values
|
||||
// if we are. We'll leverage our existing mouse motion listener
|
||||
SDLGenericMotionListener_API12 motionListener = SDLActivity.getMotionListener();
|
||||
x = motionListener.getEventX(event);
|
||||
y = motionListener.getEventY(event);
|
||||
|
||||
SDLActivity.onNativeMouse(mouseButton, action, x, y, motionListener.inRelativeMode());
|
||||
} else {
|
||||
switch(action) {
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
for (i = 0; i < pointerCount; i++) {
|
||||
pointerFingerId = event.getPointerId(i);
|
||||
x = event.getX(i) / mWidth;
|
||||
y = event.getY(i) / mHeight;
|
||||
p = event.getPressure(i);
|
||||
if (p > 1.0f) {
|
||||
// may be larger than 1.0f on some devices
|
||||
// see the documentation of getPressure(i)
|
||||
p = 1.0f;
|
||||
}
|
||||
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
|
||||
}
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_UP:
|
||||
case MotionEvent.ACTION_DOWN:
|
||||
// Primary pointer up/down, the index is always zero
|
||||
i = 0;
|
||||
/* fallthrough */
|
||||
case MotionEvent.ACTION_POINTER_UP:
|
||||
case MotionEvent.ACTION_POINTER_DOWN:
|
||||
// Non primary pointer up/down
|
||||
if (i == -1) {
|
||||
i = event.getActionIndex();
|
||||
}
|
||||
|
||||
pointerFingerId = event.getPointerId(i);
|
||||
x = event.getX(i) / mWidth;
|
||||
y = event.getY(i) / mHeight;
|
||||
p = event.getPressure(i);
|
||||
if (p > 1.0f) {
|
||||
// may be larger than 1.0f on some devices
|
||||
// see the documentation of getPressure(i)
|
||||
p = 1.0f;
|
||||
}
|
||||
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, action, x, y, p);
|
||||
break;
|
||||
|
||||
case MotionEvent.ACTION_CANCEL:
|
||||
for (i = 0; i < pointerCount; i++) {
|
||||
pointerFingerId = event.getPointerId(i);
|
||||
x = event.getX(i) / mWidth;
|
||||
y = event.getY(i) / mHeight;
|
||||
p = event.getPressure(i);
|
||||
if (p > 1.0f) {
|
||||
// may be larger than 1.0f on some devices
|
||||
// see the documentation of getPressure(i)
|
||||
p = 1.0f;
|
||||
}
|
||||
SDLActivity.onNativeTouch(touchDevId, pointerFingerId, MotionEvent.ACTION_UP, x, y, p);
|
||||
}
|
||||
break;
|
||||
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
// Sensor events
|
||||
public void enableSensor(int sensortype, boolean enabled) {
|
||||
// TODO: This uses getDefaultSensor - what if we have >1 accels?
|
||||
if (enabled) {
|
||||
mSensorManager.registerListener(this,
|
||||
mSensorManager.getDefaultSensor(sensortype),
|
||||
SensorManager.SENSOR_DELAY_GAME, null);
|
||||
} else {
|
||||
mSensorManager.unregisterListener(this,
|
||||
mSensorManager.getDefaultSensor(sensortype));
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onAccuracyChanged(Sensor sensor, int accuracy) {
|
||||
// TODO
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onSensorChanged(SensorEvent event) {
|
||||
if (event.sensor.getType() == Sensor.TYPE_ACCELEROMETER) {
|
||||
|
||||
// Since we may have an orientation set, we won't receive onConfigurationChanged events.
|
||||
// We thus should check here.
|
||||
int newOrientation;
|
||||
|
||||
float x, y;
|
||||
switch (mDisplay.getRotation()) {
|
||||
case Surface.ROTATION_90:
|
||||
x = -event.values[1];
|
||||
y = event.values[0];
|
||||
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE;
|
||||
break;
|
||||
case Surface.ROTATION_270:
|
||||
x = event.values[1];
|
||||
y = -event.values[0];
|
||||
newOrientation = SDLActivity.SDL_ORIENTATION_LANDSCAPE_FLIPPED;
|
||||
break;
|
||||
case Surface.ROTATION_180:
|
||||
x = -event.values[0];
|
||||
y = -event.values[1];
|
||||
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT_FLIPPED;
|
||||
break;
|
||||
case Surface.ROTATION_0:
|
||||
default:
|
||||
x = event.values[0];
|
||||
y = event.values[1];
|
||||
newOrientation = SDLActivity.SDL_ORIENTATION_PORTRAIT;
|
||||
break;
|
||||
}
|
||||
|
||||
if (newOrientation != SDLActivity.mCurrentOrientation) {
|
||||
SDLActivity.mCurrentOrientation = newOrientation;
|
||||
SDLActivity.onNativeOrientationChanged(newOrientation);
|
||||
}
|
||||
|
||||
SDLActivity.onNativeAccel(-x / SensorManager.GRAVITY_EARTH,
|
||||
y / SensorManager.GRAVITY_EARTH,
|
||||
event.values[2] / SensorManager.GRAVITY_EARTH);
|
||||
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
// Captured pointer events for API 26.
|
||||
public boolean onCapturedPointerEvent(MotionEvent event)
|
||||
{
|
||||
int action = event.getActionMasked();
|
||||
|
||||
float x, y;
|
||||
switch (action) {
|
||||
case MotionEvent.ACTION_SCROLL:
|
||||
x = event.getAxisValue(MotionEvent.AXIS_HSCROLL, 0);
|
||||
y = event.getAxisValue(MotionEvent.AXIS_VSCROLL, 0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, false);
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_HOVER_MOVE:
|
||||
case MotionEvent.ACTION_MOVE:
|
||||
x = event.getX(0);
|
||||
y = event.getY(0);
|
||||
SDLActivity.onNativeMouse(0, action, x, y, true);
|
||||
return true;
|
||||
|
||||
case MotionEvent.ACTION_BUTTON_PRESS:
|
||||
case MotionEvent.ACTION_BUTTON_RELEASE:
|
||||
|
||||
// Change our action value to what SDL's code expects.
|
||||
if (action == MotionEvent.ACTION_BUTTON_PRESS) {
|
||||
action = MotionEvent.ACTION_DOWN;
|
||||
} else { /* MotionEvent.ACTION_BUTTON_RELEASE */
|
||||
action = MotionEvent.ACTION_UP;
|
||||
}
|
||||
|
||||
x = event.getX(0);
|
||||
y = event.getY(0);
|
||||
int button = event.getButtonState();
|
||||
|
||||
SDLActivity.onNativeMouse(button, action, x, y, true);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 6.3 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 43 KiB |
|
After Width: | Height: | Size: 73 KiB |
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<string name="app_name">System Shock</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,25 @@
|
||||
// Top-level Gradle build file. Adapted from SDL2's own android-project
|
||||
// template (see app/src/main/java/org/libsdl/app/, copied from the same
|
||||
// template). Unlike the C/SDL2 desktop build, this needs network access
|
||||
// at build time for Gradle/AGP's own dependency resolution - see
|
||||
// build-apk.sh.
|
||||
buildscript {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
dependencies {
|
||||
classpath 'com.android.tools.build:gradle:8.1.1'
|
||||
}
|
||||
}
|
||||
|
||||
allprojects {
|
||||
repositories {
|
||||
mavenCentral()
|
||||
google()
|
||||
}
|
||||
}
|
||||
|
||||
task clean(type: Delete) {
|
||||
delete rootProject.buildDir
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -380,9 +380,25 @@
|
||||
-include precompiled.h
|
||||
)
|
||||
|
||||
-add_executable(systemshock
|
||||
- ${MAC_SRC}
|
||||
-)
|
||||
+# Android has no standalone executables (Java loads a shared library via
|
||||
+# JNI instead), so this is the one part of this file Android needs to
|
||||
+# override - see android/engine-patches/. Everything else here (sources,
|
||||
+# dependency discovery, link libraries) is untouched and applies the same
|
||||
+# way on both platforms. ANDROID_EXTRA_SOURCES (passed in by
|
||||
+# android/app/build.gradle) is QuestShockActivity's own small native
|
||||
+# helper (android/app/src/main/cpp/) - not part of engine/ itself.
|
||||
+if(ANDROID)
|
||||
+ set(SHOCKOLATE_TARGET main)
|
||||
+ add_library(${SHOCKOLATE_TARGET} SHARED
|
||||
+ ${MAC_SRC}
|
||||
+ ${ANDROID_EXTRA_SOURCES}
|
||||
+ )
|
||||
+else()
|
||||
+ set(SHOCKOLATE_TARGET systemshock)
|
||||
+ add_executable(${SHOCKOLATE_TARGET}
|
||||
+ ${MAC_SRC}
|
||||
+ )
|
||||
+endif()
|
||||
|
||||
add_library(GAME_LIB ${GAME_SRC})
|
||||
|
||||
@@ -391,7 +407,7 @@
|
||||
set(WINDOWS_LIBRARIES "mingw32 -mwindows")
|
||||
endif(MINGW)
|
||||
|
||||
-target_link_libraries(systemshock
|
||||
+target_link_libraries(${SHOCKOLATE_TARGET}
|
||||
${WINDOWS_LIBRARIES} # Set it before any linker options! Beware WinMain@16 error!!
|
||||
GAME_LIB
|
||||
UI_LIB
|
||||
@@ -418,7 +434,10 @@
|
||||
${ALSA_LIBRARIES}
|
||||
)
|
||||
|
||||
-# Turn on address sanitizing if wanted
|
||||
-set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/externals/sanitizers/cmake" ${CMAKE_MODULE_PATH})
|
||||
-find_package(Sanitizers)
|
||||
-add_sanitizers(systemshock)
|
||||
+# Turn on address sanitizing if wanted (desktop only - not meaningful for
|
||||
+# an Android release build)
|
||||
+if(NOT ANDROID)
|
||||
+ set(CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/externals/sanitizers/cmake" ${CMAKE_MODULE_PATH})
|
||||
+ find_package(Sanitizers)
|
||||
+ add_sanitizers(${SHOCKOLATE_TARGET})
|
||||
+endif()
|
||||
@@ -0,0 +1,22 @@
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -46,9 +46,17 @@
|
||||
|
||||
add_compile_options(-fsigned-char -fno-strict-aliasing)
|
||||
|
||||
-# Find OpenGL
|
||||
+# Find OpenGL. Android has no desktop GL/GLX for CMake's FindOpenGL module
|
||||
+# to find - link the prebuilt GL4ES instead (translates this file's
|
||||
+# desktop-style GL calls into GLES/EGL; it links GLESv2/EGL itself, so
|
||||
+# the engine doesn't need to link them directly).
|
||||
if(ENABLE_OPENGL)
|
||||
- find_package(OpenGL REQUIRED)
|
||||
+ if(ANDROID)
|
||||
+ set(OPENGL_INCLUDE_DIRS ${ANDROID_PREBUILT_DIR}/gl4es/include)
|
||||
+ set(OPENGL_LIBRARIES ${ANDROID_PREBUILT_DIR}/gl4es/lib/libGL.so)
|
||||
+ else()
|
||||
+ find_package(OpenGL REQUIRED)
|
||||
+ endif()
|
||||
add_definitions(-DUSE_OPENGL)
|
||||
if(WIN32)
|
||||
list(APPEND OPENGL_INCLUDE_DIRS ${CMAKE_SOURCE_DIR}/build_ext/built_glew/include)
|
||||
@@ -0,0 +1,24 @@
|
||||
--- a/src/MacSrc/Shock.c 2026-07-19 21:10:52.654910439 +0200
|
||||
+++ b/src/MacSrc/Shock.c 2026-07-19 21:10:52.658910394 +0200
|
||||
@@ -167,9 +167,21 @@
|
||||
}
|
||||
|
||||
// TODO: figure out some universal set of settings that work...
|
||||
+#ifdef __ANDROID__
|
||||
+ // Desktop GL's compatibility/core profile split doesn't exist on
|
||||
+ // Android - EGL only ever gives out GLES contexts. Quest hardware
|
||||
+ // (Adreno on the XR2/XR2 Gen 2) supports GLES 3.2, which - unlike
|
||||
+ // GLES 2.0 - has GL_UNPACK_ROW_LENGTH and GL_CLAMP_TO_BORDER as core
|
||||
+ // (see android/engine-patches/03-android-opengl-es-render.patch),
|
||||
+ // avoiding needing workarounds for either in OpenGL.cc.
|
||||
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_ES);
|
||||
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 3);
|
||||
+ SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 2);
|
||||
+#else
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_PROFILE_MASK, SDL_GL_CONTEXT_PROFILE_CORE);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MAJOR_VERSION, 2);
|
||||
SDL_GL_SetAttribute(SDL_GL_CONTEXT_MINOR_VERSION, 0);
|
||||
+#endif
|
||||
SDL_GL_SetAttribute(SDL_GL_RED_SIZE, 8);
|
||||
SDL_GL_SetAttribute(SDL_GL_GREEN_SIZE, 8);
|
||||
SDL_GL_SetAttribute(SDL_GL_BLUE_SIZE, 8);
|
||||
@@ -0,0 +1,75 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -20,6 +20,24 @@
|
||||
#include <SDL_opengl.h>
|
||||
#endif
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+// GL4ES's glPointSize()/custom-shader interop goes through the same kind
|
||||
+// of shader-source rewrite as its alpha-test emulation (see
|
||||
+// init_opengl() below) - avoid relying on it. GLES point size instead
|
||||
+// comes from a "pointSize" uniform, written to gl_PointSize in
|
||||
+// android/gles-shaders/main.vert. opengl_begin_stars() (the only
|
||||
+// glPointSize() call site) now binds the star shader first, so
|
||||
+// GL_CURRENT_PROGRAM here is always correct.
|
||||
+#define glPointSize questshock_glPointSize
|
||||
+static void questshock_glPointSize(GLfloat size) {
|
||||
+ GLint program = 0;
|
||||
+ glGetIntegerv(GL_CURRENT_PROGRAM, &program);
|
||||
+ GLint loc = glGetUniformLocation(program, "pointSize");
|
||||
+ if (loc >= 0)
|
||||
+ glUniform1f(loc, size);
|
||||
+}
|
||||
+#endif // __ANDROID__
|
||||
+
|
||||
extern "C" {
|
||||
#include "mainloop.h"
|
||||
#include "map.h"
|
||||
@@ -229,6 +247,13 @@
|
||||
GLuint shaderProgram = glCreateProgram();
|
||||
glAttachShader(shaderProgram, vertShader);
|
||||
glAttachShader(shaderProgram, fragShader);
|
||||
+#ifdef __ANDROID__
|
||||
+ // Must be bound before linking. GL4ES routes glVertex3f() (used
|
||||
+ // throughout this file below) to attribute location 0 of whatever
|
||||
+ // shader is currently bound - matches
|
||||
+ // android/gles-shaders/main.vert's "position" attribute.
|
||||
+ glBindAttribLocation(shaderProgram, 0, "position");
|
||||
+#endif
|
||||
glLinkProgram(shaderProgram);
|
||||
glUseProgram(shaderProgram);
|
||||
|
||||
@@ -313,10 +338,18 @@
|
||||
|
||||
glEnable(GL_CULL_FACE);
|
||||
glEnable(GL_BLEND);
|
||||
+#ifndef __ANDROID__
|
||||
+ // GL4ES emulates both of these (alpha test, point sprites) for a
|
||||
+ // custom shader by rewriting its source under the hood - a rougher
|
||||
+ // edge of the library. Skip relying on that: alpha test is instead
|
||||
+ // a discard in android/gles-shaders/texture.frag, and point-sprite
|
||||
+ // rasterization for GL_POINTS works automatically in GLES with no
|
||||
+ // enable call at all (see android/gles-shaders/star.frag).
|
||||
glEnable(GL_ALPHA_TEST);
|
||||
glEnable(GL_POINT_SPRITE);
|
||||
- glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
glAlphaFunc(GL_GEQUAL, 0.05f);
|
||||
+#endif
|
||||
+ glBlendFunc(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA);
|
||||
|
||||
CreateShader("main.vert", "texture.frag", &textureShaderProgram);
|
||||
CreateShader("main.vert", "color.frag", &colorShaderProgram);
|
||||
@@ -908,9 +941,12 @@
|
||||
void opengl_begin_stars() {
|
||||
SDL_GL_MakeCurrent(window, context);
|
||||
|
||||
+ // Bind the star shader before setting the point size: on Android,
|
||||
+ // questshock_glPointSize() (see the __ANDROID__ block above) needs
|
||||
+ // it to already be GL_CURRENT_PROGRAM.
|
||||
+ glUseProgram(starShaderProgram.shaderProgram);
|
||||
glPointSize(1.5 * (render_width / 320.0));
|
||||
|
||||
- glUseProgram(starShaderProgram.shaderProgram);
|
||||
glUniformMatrix4fv(starShaderProgram.uniView, 1, false, IdentityMatrix);
|
||||
glUniformMatrix4fv(starShaderProgram.uniProj, 1, false, IdentityMatrix);
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
--- a/src/MacSrc/Shock.c
|
||||
+++ b/src/MacSrc/Shock.c
|
||||
@@ -162,6 +162,17 @@
|
||||
void InitSDL() {
|
||||
SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1");
|
||||
SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl");
|
||||
+#ifdef __ANDROID__
|
||||
+ // SDL2 2.28.5's AAudio backend (Android's default since API 26) only
|
||||
+ // ever allows one open non-capture (playback) device at a time - it
|
||||
+ // keeps a single static handle and asserts on a second open
|
||||
+ // ('SDL_assert((audioDevice == NULL) || iscapture)' in
|
||||
+ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via
|
||||
+ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for
|
||||
+ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force
|
||||
+ // it instead of AAudio.
|
||||
+ SDL_SetHint(SDL_HINT_AUDIODRIVER, "openslES");
|
||||
+#endif
|
||||
if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) {
|
||||
DEBUG("%s: Init failed", __FUNCTION__);
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
--- a/src/Libraries/INPUT/Source/sdl_events.c
|
||||
+++ b/src/Libraries/INPUT/Source/sdl_events.c
|
||||
@@ -737,7 +737,24 @@
|
||||
break;
|
||||
|
||||
case SDL_WINDOWEVENT_MOVED:
|
||||
+ break;
|
||||
+
|
||||
case SDL_WINDOWEVENT_RESIZED:
|
||||
+#ifdef __ANDROID__
|
||||
+ // Android's SDL video backend (Android_SendResize() in
|
||||
+ // src/video/android/SDL_androidvideo.c) only ever sends
|
||||
+ // RESIZED for surface-driven resizes - e.g. the Quest Home
|
||||
+ // shell settling the 2D panel into its requested <layout>
|
||||
+ // defaultWidth/defaultHeight after activity launch - never
|
||||
+ // SIZE_CHANGED (unlike desktop platforms, where an
|
||||
+ // app-driven SDL_SetWindowSize() triggers both, handled
|
||||
+ // above). Without this, opengl_resize() never re-runs after
|
||||
+ // that late resize, leaving the GL viewport stuck at
|
||||
+ // whatever (smaller) size the window first reported,
|
||||
+ // rendered into one corner of the now-larger surface.
|
||||
+ if (can_use_opengl())
|
||||
+ opengl_resize(ev.window.data1, ev.window.data2);
|
||||
+#endif
|
||||
break;
|
||||
|
||||
case SDL_WINDOWEVENT_FOCUS_GAINED:
|
||||
@@ -0,0 +1,24 @@
|
||||
--- a/src/MacSrc/OpenGL.cc
|
||||
+++ b/src/MacSrc/OpenGL.cc
|
||||
@@ -332,6 +332,21 @@
|
||||
|
||||
int width, height;
|
||||
SDL_GetWindowSize(window, &width, &height);
|
||||
+#ifdef __ANDROID__
|
||||
+ // Temporary diagnostic: the reported panel/surface size is correct
|
||||
+ // (confirmed via SDLSurface's own "Window size" log and a Java-side
|
||||
+ // onSizeChanged() probe showing no later relayout), but the rendered
|
||||
+ // content still only fills a small corner of it. That means the
|
||||
+ // divergence is somewhere below the Java/Activity layer - between what
|
||||
+ // SDL_GetWindowSize() (the value used for opengl_resize() below) reports
|
||||
+ // and what SDL/EGL/gl4es actually believe the live drawable size is.
|
||||
+ // Compare all three directly instead of guessing further.
|
||||
+ int drawable_w, drawable_h, output_w, output_h;
|
||||
+ SDL_GL_GetDrawableSize(window, &drawable_w, &drawable_h);
|
||||
+ SDL_GetRendererOutputSize(renderer, &output_w, &output_h);
|
||||
+ INFO("Android size diag: SDL_GetWindowSize=%dx%d SDL_GL_GetDrawableSize=%dx%d SDL_GetRendererOutputSize=%dx%d",
|
||||
+ width, height, drawable_w, drawable_h, output_w, output_h);
|
||||
+#endif
|
||||
opengl_resize(width, height);
|
||||
|
||||
// Now make the palettes
|
||||
@@ -0,0 +1,10 @@
|
||||
--- a/CMakeLists.txt
|
||||
+++ b/CMakeLists.txt
|
||||
@@ -440,6 +440,7 @@
|
||||
${FLUIDSYNTH_LIBRARIES}
|
||||
${OPENGL_LIBRARIES}
|
||||
${ALSA_LIBRARIES}
|
||||
+ $<$<BOOL:${ANDROID}>:log>
|
||||
)
|
||||
|
||||
# Turn on address sanitizing if wanted (desktop only - not meaningful for
|
||||
@@ -0,0 +1,45 @@
|
||||
--- a/src/Libraries/LG/Source/LOG/src/log.c
|
||||
+++ b/src/Libraries/LG/Source/LOG/src/log.c
|
||||
@@ -28,6 +28,10 @@
|
||||
|
||||
#include "log.h"
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+#include <android/log.h>
|
||||
+#endif
|
||||
+
|
||||
static struct {
|
||||
void *udata;
|
||||
log_LockFn lock;
|
||||
@@ -99,6 +103,23 @@
|
||||
time_t t = time(NULL);
|
||||
struct tm *lt = localtime(&t);
|
||||
|
||||
+#ifdef __ANDROID__
|
||||
+ /* Plain stdout/stderr isn't captured by logcat on this platform (unlike
|
||||
+ e.g. gl4es's own "LIBGL"-tagged logging, which goes through this same
|
||||
+ API directly) - route there instead so every existing INFO/DEBUG/WARN/
|
||||
+ ERROR call site in the engine becomes visible for on-device debugging,
|
||||
+ with no changes needed at those call sites. */
|
||||
+ if (!L.quiet) {
|
||||
+ static const int android_priority[] = {
|
||||
+ ANDROID_LOG_VERBOSE, ANDROID_LOG_DEBUG, ANDROID_LOG_INFO,
|
||||
+ ANDROID_LOG_WARN, ANDROID_LOG_ERROR, ANDROID_LOG_FATAL
|
||||
+ };
|
||||
+ va_list args;
|
||||
+ va_start(args, fmt);
|
||||
+ __android_log_vprint(android_priority[level], "QuestShock", fmt, args);
|
||||
+ va_end(args);
|
||||
+ }
|
||||
+#else
|
||||
/* Log to stderr */
|
||||
if (!L.quiet) {
|
||||
va_list args;
|
||||
@@ -116,6 +137,7 @@
|
||||
va_end(args);
|
||||
fprintf(stderr, "\n");
|
||||
}
|
||||
+#endif
|
||||
|
||||
/* Log to file */
|
||||
if (L.fp) {
|
||||
@@ -0,0 +1,31 @@
|
||||
--- a/src/MacSrc/ShockBitmap.c
|
||||
+++ b/src/MacSrc/ShockBitmap.c
|
||||
@@ -42,11 +42,28 @@
|
||||
|
||||
SDL_RenderClear(renderer);
|
||||
|
||||
+#ifndef __ANDROID__
|
||||
+ // On Android there's exactly one OS-controlled-size surface - no
|
||||
+ // desktop-style window to resize, move, or toggle fullscreen on.
|
||||
+ // SDL_SetWindowSize() still "succeeds" there (Android has no
|
||||
+ // SetWindowSize driver hook, so SDL's generic layer just overwrites its
|
||||
+ // own cached window->w/h to the requested game resolution, e.g.
|
||||
+ // 640x480, and synthesizes a resize event from that) - which desyncs
|
||||
+ // SDL's own notion of the window size from the real, unchanged Android
|
||||
+ // surface size (e.g. 1600x1200), and that desync is exactly what then
|
||||
+ // makes both SDL's internal renderer viewport and this engine's own
|
||||
+ // custom GL viewport (OpenGL.cc's opengl_resize(), driven by that same
|
||||
+ // now-wrong cached size) shrink to the game's resolution instead of
|
||||
+ // filling the real surface - confirmed on-device via added logcat
|
||||
+ // diagnostics (see android/engine-patches/07-09) showing the correct
|
||||
+ // 1600x1200 size at startup, then this exact call sequence collapsing
|
||||
+ // it to 640x480 the moment the splash screen sets its video mode.
|
||||
extern bool fullscreenActive;
|
||||
SDL_SetWindowFullscreen(window, fullscreenActive ? SDL_WINDOW_FULLSCREEN_DESKTOP : 0);
|
||||
|
||||
SDL_SetWindowSize(window, width, height);
|
||||
SDL_SetWindowPosition(window, SDL_WINDOWPOS_CENTERED, SDL_WINDOWPOS_CENTERED);
|
||||
+#endif
|
||||
|
||||
SDL_RenderSetLogicalSize(renderer, width, height);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
#version 100
|
||||
|
||||
// GLES port of engine/shaders/color.frag - identical logic, just needs an
|
||||
// explicit precision declaration (required in GLES fragment shaders, not
|
||||
// valid in desktop GLSL).
|
||||
precision mediump float;
|
||||
|
||||
varying vec4 Color;
|
||||
varying float Light;
|
||||
|
||||
void main() {
|
||||
gl_FragColor = vec4(Color.r * Light, Color.g * Light, Color.b * Light, Color.a);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
#version 100
|
||||
|
||||
// GLES port of engine/shaders/main.vert - the one real difference is
|
||||
// gl_Vertex, a desktop-only compatibility-profile builtin that doesn't
|
||||
// exist in GLES at all, replaced with an explicit "position" attribute
|
||||
// (see android/engine-patches/03-android-opengl-es-render.patch's
|
||||
// CreateShader() change, which binds it to attribute location 0 to match
|
||||
// the vertex arrays OpenGL.cc's GLES immediate-mode emulation submits).
|
||||
precision mediump float;
|
||||
|
||||
attribute vec4 position;
|
||||
attribute vec2 texcoords;
|
||||
attribute float light;
|
||||
attribute vec4 color;
|
||||
|
||||
varying vec2 TexCoords;
|
||||
varying float Light;
|
||||
varying vec4 Color;
|
||||
|
||||
uniform mat4 view;
|
||||
uniform mat4 proj;
|
||||
|
||||
// GLES has no glPointSize() (desktop-only, set from
|
||||
// opengl_begin_stars()) - point size must come from this builtin
|
||||
// instead. Only read by the rasterizer for GL_POINTS draws (i.e. only
|
||||
// matters for star.frag); harmless left unset for the other two shaders
|
||||
// built from this same vertex shader (main.vert). See
|
||||
// android/engine-patches/04-android-opengl-es-render.patch's
|
||||
// questshock_glPointSize()/questshock_glEnd().
|
||||
uniform float pointSize;
|
||||
|
||||
void main() {
|
||||
TexCoords = texcoords;
|
||||
Light = light;
|
||||
Color = color;
|
||||
gl_Position = proj * view * position;
|
||||
gl_PointSize = pointSize;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
#version 100
|
||||
|
||||
// GLES port of engine/shaders/star.frag - identical logic, plus an
|
||||
// explicit precision declaration (required in GLES). gl_PointCoord and
|
||||
// point-sprite rasterization for GL_POINTS both work automatically in
|
||||
// GLES with no separate enable call (unlike desktop's GL_POINT_SPRITE,
|
||||
// which doesn't exist in GLES at all - see
|
||||
// android/engine-patches/03-android-opengl-es-render.patch's
|
||||
// init_opengl() change).
|
||||
precision mediump float;
|
||||
|
||||
varying float Light;
|
||||
|
||||
void main() {
|
||||
vec2 mid = vec2(0.5, 0.5) - gl_PointCoord;
|
||||
|
||||
float dist = 0.5 - length(mid);
|
||||
dist *= 3.0;
|
||||
dist = min(dist, 1.0);
|
||||
|
||||
gl_FragColor = vec4(dist * Light, dist * Light, dist * Light, 1.0);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
#version 100
|
||||
|
||||
// GLES port of engine/shaders/texture.frag - same logic, plus:
|
||||
// - an explicit precision declaration (required in GLES, invalid in
|
||||
// desktop GLSL).
|
||||
// - the discard at the end, replacing the fixed-function alpha test
|
||||
// (glAlphaFunc(GL_GEQUAL, 0.05f) on desktop - GLES has no fixed-function
|
||||
// pipeline at all, so this is done in the shader instead; see
|
||||
// android/engine-patches/03-android-opengl-es-render.patch's
|
||||
// init_opengl() change, which skips the desktop-only glAlphaFunc/
|
||||
// GL_ALPHA_TEST calls on Android).
|
||||
precision mediump float;
|
||||
|
||||
// The texture alpha value has two separate functions in this shader:
|
||||
// - Pixels with a zero alpha value are transparent.
|
||||
// - Pixels with non-zero alpha are opaque, and the alpha value
|
||||
// defines a minimum light level.
|
||||
|
||||
varying vec2 TexCoords;
|
||||
varying float Light;
|
||||
|
||||
uniform sampler2D tex;
|
||||
uniform bool nightsight;
|
||||
uniform bool mutant;
|
||||
|
||||
void main() {
|
||||
vec4 t = texture2D(tex, TexCoords);
|
||||
|
||||
// Alpha values > 0.5 are emissive
|
||||
float alpha = t.a > 0.5 ? 1.0 : t.a * 2.0;
|
||||
|
||||
if (nightsight) {
|
||||
// approximate the blue tint of palette colors 0xb0..0xbf
|
||||
float gray = 0.40 * t.r + 0.59 * t.g + 0.11 * t.b;
|
||||
float rg = 0.7 * gray;
|
||||
float b = 0.75 * gray + 0.1;
|
||||
gl_FragColor = vec4(rg, rg, b, (mutant ? 0.2 * alpha : alpha));
|
||||
} else {
|
||||
float emissive = t.a > 0.5 ? (t.a - 0.5) * 2.0 : 0.0;
|
||||
float light = max(Light * Light, emissive);
|
||||
if (mutant)
|
||||
gl_FragColor = vec4(0.5 * t.r * light, 0.5 * t.r * light, 0.5 * t.r * light, 0.2 * alpha);
|
||||
else
|
||||
gl_FragColor = vec4(t.r * light, t.g * light, t.b * light, alpha);
|
||||
}
|
||||
|
||||
if (gl_FragColor.a < 0.05)
|
||||
discard;
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
org.gradle.jvmargs=-Xmx1536m
|
||||
android.useAndroidX=true
|
||||
@@ -0,0 +1,6 @@
|
||||
#Thu Nov 11 18:20:34 PST 2021
|
||||
distributionBase=GRADLE_USER_HOME
|
||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1.1-bin.zip
|
||||
distributionPath=wrapper/dists
|
||||
zipStorePath=wrapper/dists
|
||||
zipStoreBase=GRADLE_USER_HOME
|
||||
@@ -0,0 +1,160 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
##############################################################################
|
||||
##
|
||||
## Gradle start up script for UN*X
|
||||
##
|
||||
##############################################################################
|
||||
|
||||
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
DEFAULT_JVM_OPTS=""
|
||||
|
||||
APP_NAME="Gradle"
|
||||
APP_BASE_NAME=`basename "$0"`
|
||||
|
||||
# Use the maximum available, or set MAX_FD != -1 to use that value.
|
||||
MAX_FD="maximum"
|
||||
|
||||
warn ( ) {
|
||||
echo "$*"
|
||||
}
|
||||
|
||||
die ( ) {
|
||||
echo
|
||||
echo "$*"
|
||||
echo
|
||||
exit 1
|
||||
}
|
||||
|
||||
# OS specific support (must be 'true' or 'false').
|
||||
cygwin=false
|
||||
msys=false
|
||||
darwin=false
|
||||
case "`uname`" in
|
||||
CYGWIN* )
|
||||
cygwin=true
|
||||
;;
|
||||
Darwin* )
|
||||
darwin=true
|
||||
;;
|
||||
MINGW* )
|
||||
msys=true
|
||||
;;
|
||||
esac
|
||||
|
||||
# Attempt to set APP_HOME
|
||||
# Resolve links: $0 may be a link
|
||||
PRG="$0"
|
||||
# Need this for relative symlinks.
|
||||
while [ -h "$PRG" ] ; do
|
||||
ls=`ls -ld "$PRG"`
|
||||
link=`expr "$ls" : '.*-> \(.*\)$'`
|
||||
if expr "$link" : '/.*' > /dev/null; then
|
||||
PRG="$link"
|
||||
else
|
||||
PRG=`dirname "$PRG"`"/$link"
|
||||
fi
|
||||
done
|
||||
SAVED="`pwd`"
|
||||
cd "`dirname \"$PRG\"`/" >/dev/null
|
||||
APP_HOME="`pwd -P`"
|
||||
cd "$SAVED" >/dev/null
|
||||
|
||||
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
|
||||
|
||||
# Determine the Java command to use to start the JVM.
|
||||
if [ -n "$JAVA_HOME" ] ; then
|
||||
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
|
||||
# IBM's JDK on AIX uses strange locations for the executables
|
||||
JAVACMD="$JAVA_HOME/jre/sh/java"
|
||||
else
|
||||
JAVACMD="$JAVA_HOME/bin/java"
|
||||
fi
|
||||
if [ ! -x "$JAVACMD" ] ; then
|
||||
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
else
|
||||
JAVACMD="java"
|
||||
which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
|
||||
Please set the JAVA_HOME variable in your environment to match the
|
||||
location of your Java installation."
|
||||
fi
|
||||
|
||||
# Increase the maximum file descriptors if we can.
|
||||
if [ "$cygwin" = "false" -a "$darwin" = "false" ] ; then
|
||||
MAX_FD_LIMIT=`ulimit -H -n`
|
||||
if [ $? -eq 0 ] ; then
|
||||
if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then
|
||||
MAX_FD="$MAX_FD_LIMIT"
|
||||
fi
|
||||
ulimit -n $MAX_FD
|
||||
if [ $? -ne 0 ] ; then
|
||||
warn "Could not set maximum file descriptor limit: $MAX_FD"
|
||||
fi
|
||||
else
|
||||
warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT"
|
||||
fi
|
||||
fi
|
||||
|
||||
# For Darwin, add options to specify how the application appears in the dock
|
||||
if $darwin; then
|
||||
GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\""
|
||||
fi
|
||||
|
||||
# For Cygwin, switch paths to Windows format before running java
|
||||
if $cygwin ; then
|
||||
APP_HOME=`cygpath --path --mixed "$APP_HOME"`
|
||||
CLASSPATH=`cygpath --path --mixed "$CLASSPATH"`
|
||||
JAVACMD=`cygpath --unix "$JAVACMD"`
|
||||
|
||||
# We build the pattern for arguments to be converted via cygpath
|
||||
ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null`
|
||||
SEP=""
|
||||
for dir in $ROOTDIRSRAW ; do
|
||||
ROOTDIRS="$ROOTDIRS$SEP$dir"
|
||||
SEP="|"
|
||||
done
|
||||
OURCYGPATTERN="(^($ROOTDIRS))"
|
||||
# Add a user-defined pattern to the cygpath arguments
|
||||
if [ "$GRADLE_CYGPATTERN" != "" ] ; then
|
||||
OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)"
|
||||
fi
|
||||
# Now convert the arguments - kludge to limit ourselves to /bin/sh
|
||||
i=0
|
||||
for arg in "$@" ; do
|
||||
CHECK=`echo "$arg"|grep -E -c "$OURCYGPATTERN" -`
|
||||
CHECK2=`echo "$arg"|grep -E -c "^-"` ### Determine if an option
|
||||
|
||||
if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition
|
||||
eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"`
|
||||
else
|
||||
eval `echo args$i`="\"$arg\""
|
||||
fi
|
||||
i=$((i+1))
|
||||
done
|
||||
case $i in
|
||||
(0) set -- ;;
|
||||
(1) set -- "$args0" ;;
|
||||
(2) set -- "$args0" "$args1" ;;
|
||||
(3) set -- "$args0" "$args1" "$args2" ;;
|
||||
(4) set -- "$args0" "$args1" "$args2" "$args3" ;;
|
||||
(5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;;
|
||||
(6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;;
|
||||
(7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;;
|
||||
(8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;;
|
||||
(9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Split up the JVM_OPTS And GRADLE_OPTS values into an array, following the shell quoting and substitution rules
|
||||
function splitJvmOpts() {
|
||||
JVM_OPTS=("$@")
|
||||
}
|
||||
eval splitJvmOpts $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS
|
||||
JVM_OPTS[${#JVM_OPTS[*]}]="-Dorg.gradle.appname=$APP_BASE_NAME"
|
||||
|
||||
exec "$JAVACMD" "${JVM_OPTS[@]}" -classpath "$CLASSPATH" org.gradle.wrapper.GradleWrapperMain "$@"
|
||||
@@ -0,0 +1,90 @@
|
||||
@if "%DEBUG%" == "" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS=
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%" == "" set DIRNAME=.
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if "%ERRORLEVEL%" == "0" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto init
|
||||
|
||||
echo.
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME%
|
||||
echo.
|
||||
echo Please set the JAVA_HOME variable in your environment to match the
|
||||
echo location of your Java installation.
|
||||
|
||||
goto fail
|
||||
|
||||
:init
|
||||
@rem Get command-line arguments, handling Windowz variants
|
||||
|
||||
if not "%OS%" == "Windows_NT" goto win9xME_args
|
||||
if "%@eval[2+2]" == "4" goto 4NT_args
|
||||
|
||||
:win9xME_args
|
||||
@rem Slurp the command line arguments.
|
||||
set CMD_LINE_ARGS=
|
||||
set _SKIP=2
|
||||
|
||||
:win9xME_args_slurp
|
||||
if "x%~1" == "x" goto execute
|
||||
|
||||
set CMD_LINE_ARGS=%*
|
||||
goto execute
|
||||
|
||||
:4NT_args
|
||||
@rem Get arguments from the 4NT Shell from JP Software
|
||||
set CMD_LINE_ARGS=%$
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS%
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if "%ERRORLEVEL%"=="0" goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1
|
||||
exit /b 1
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@@ -0,0 +1,2 @@
|
||||
rootProject.name = "questshock"
|
||||
include ':app'
|
||||
@@ -24,6 +24,40 @@ ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2
|
||||
# by anything else.
|
||||
ARG NODE_VERSION=20.18.1
|
||||
|
||||
# Android layer, for the Quest APK build (Quest 2, 3, 3S, Pro - all
|
||||
# arm64-v8a, see build-apk.sh). Same SDL2 major version as the desktop
|
||||
# build above, but SDL2/SDL2_mixer are much newer releases here - unlike
|
||||
# the desktop autotools build, Android needs their real CMake+NDK build
|
||||
# support, which only landed in later releases. targetSdkVersion 29 (not
|
||||
# compileSdk, which can and does stay newer) is deliberate: paired with
|
||||
# requestLegacyExternalStorage in the manifest, it keeps /sdcard a plain,
|
||||
# unrestricted shared folder instead of hitting Android 11+ scoped
|
||||
# storage - this is keyed off the *app's* targetSdkVersion, not the
|
||||
# device's own Android version, so it keeps working on newer Quest
|
||||
# hardware/OS updates too, not just whatever's current today.
|
||||
ARG ANDROID_SDL2_VERSION=2.28.5
|
||||
ARG ANDROID_SDL2_MIXER_VERSION=2.8.0
|
||||
ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708
|
||||
ARG ANDROID_PLATFORM_VERSION=29
|
||||
# compileSdk in android/app/build.gradle - separate from the app's own
|
||||
# targetSdkVersion (29, above): compileSdk is just which android.jar the
|
||||
# app compiles against, and androidx.core needs a newer one than 29.
|
||||
ARG ANDROID_COMPILE_SDK_VERSION=34
|
||||
ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0
|
||||
ARG ANDROID_NDK_VERSION=26.1.10909125
|
||||
ARG ANDROID_CMAKE_VERSION=3.22.1
|
||||
# NDK 26 doesn't yet default to 16 KB-aligned ELF LOAD segments (that only
|
||||
# became automatic in NDK 28+) - Google requires this for Play Store
|
||||
# submissions targeting Android 15+ as of Nov 2025, and it's cheap/harmless
|
||||
# to do regardless of Play Store status. Passed to every Android shared-lib
|
||||
# CMake configure below, and to the engine's own build via
|
||||
# android/app/build.gradle's externalNativeBuild.cmake.arguments.
|
||||
ARG ANDROID_16KB_LDFLAGS="-Wl,-z,max-page-size=16384"
|
||||
# GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's
|
||||
# desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so
|
||||
# engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android.
|
||||
ARG ANDROID_GL4ES_VERSION=1.1.6
|
||||
|
||||
ENV DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# build-essential/cmake/make: engine/ itself (CMake) and SDL2/SDL2_mixer
|
||||
@@ -41,11 +75,26 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# libogg-dev/libvorbis-dev: SDL2_mixer's OGG Vorbis music decoder.
|
||||
# libasound2-dev: engine/CMakeLists.txt's optional native ALSA MIDI output.
|
||||
# openssh-client: the CI workflow's `sftp` publish step.
|
||||
# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the APK build.
|
||||
# unzip: extracts the Android cmdline-tools zip below.
|
||||
#
|
||||
# All apt installs deliberately live in this one RUN, first, so editing
|
||||
# anything below it (in particular the Android cross-compile steps, the
|
||||
# newest and least settled part of this file) never invalidates Docker's
|
||||
# build cache for this layer - and vice versa, adding a package here only
|
||||
# ever costs a rebuild of the (slow) layers below it, not a re-download
|
||||
# of packages that didn't change.
|
||||
# patchelf: fixes up GL4ES's built-in ELF SONAME below (see the gl4es
|
||||
# build step) - the Android APK packager (AGP) refuses to bundle a
|
||||
# jniLibs file whose name doesn't literally end in ".so", but the
|
||||
# dynamic linker resolves NEEDED entries by embedded SONAME, not
|
||||
# filename - patchelf lets both agree on "libGL.so".
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
build-essential cmake make git curl ca-certificates pkg-config gosu \
|
||||
libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxrandr-dev \
|
||||
libxi-dev libxfixes-dev libxss-dev libxinerama-dev libxcursor-dev \
|
||||
libogg-dev libvorbis-dev libasound2-dev openssh-client \
|
||||
openjdk-17-jdk-headless unzip patchelf \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /opt/prebuilt
|
||||
@@ -95,6 +144,134 @@ RUN curl -sSL -o /tmp/node.tar.xz \
|
||||
&& tar -xJf /tmp/node.tar.xz -C /usr/local --strip-components=1 \
|
||||
&& rm /tmp/node.tar.xz
|
||||
|
||||
# Android SDK/NDK: the cmdline-tools' own sdkmanager installs exactly the
|
||||
# platform/build-tools/NDK/CMake versions the app/build.gradle below pins.
|
||||
# gradlew (invoked by build-apk.sh) resolves its own pinned Gradle version
|
||||
# itself - no Gradle binary is baked into this image. Unlike the manually
|
||||
# cross-compiled SDL2/SDL2_mixer/fluidsynth-lite below, `./gradlew
|
||||
# assembleDebug` still needs network access at build time for Gradle/AGP's
|
||||
# own dependency resolution - an accepted, deliberate exception to this
|
||||
# image's otherwise-offline build philosophy (see build-apk.sh).
|
||||
ENV ANDROID_HOME=/opt/android-sdk \
|
||||
ANDROID_SDK_ROOT=/opt/android-sdk
|
||||
ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HOME}/platform-tools:${PATH}
|
||||
|
||||
RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \
|
||||
&& curl -sSL -o /tmp/cmdline-tools.zip \
|
||||
"https://dl.google.com/android/repository/commandlinetools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \
|
||||
&& unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmdline-tools" \
|
||||
&& mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${ANDROID_HOME}/cmdline-tools/latest" \
|
||||
&& rm /tmp/cmdline-tools.zip \
|
||||
&& yes | sdkmanager --licenses >/dev/null \
|
||||
&& sdkmanager --install \
|
||||
"platform-tools" \
|
||||
"platforms;android-${ANDROID_PLATFORM_VERSION}" \
|
||||
"platforms;android-${ANDROID_COMPILE_SDK_VERSION}" \
|
||||
"build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \
|
||||
"ndk;${ANDROID_NDK_VERSION}" \
|
||||
"cmake;${ANDROID_CMAKE_VERSION}"
|
||||
|
||||
ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION}
|
||||
ENV ANDROID_NDK_TOOLCHAIN=${ANDROID_NDK_HOME}/build/cmake/android.toolchain.cmake
|
||||
|
||||
# SDL2, SDL2_mixer, and fluidsynth-lite, cross-compiled for arm64-v8a (the
|
||||
# Quest's only ABI, across Quest 2/3/3S/Pro) via the NDK's CMake toolchain file, each installed
|
||||
# to its own prefix under /opt/prebuilt/android/ - mirrors the desktop
|
||||
# build_ext/built_sdl / built_sdl_mixer layout, just for Android. Shipped
|
||||
# in the APK as separate .so's (see build-apk.sh), loaded in that order by
|
||||
# QuestShockActivity.getLibraries() before the game's own libmain.so.
|
||||
ARG ANDROID_ABI=arm64-v8a
|
||||
|
||||
RUN curl -sSLO "https://www.libsdl.org/release/SDL2-${ANDROID_SDL2_VERSION}.tar.gz" \
|
||||
&& tar xf "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" \
|
||||
&& cmake -S "SDL2-${ANDROID_SDL2_VERSION}" -B build-sdl2-android \
|
||||
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
|
||||
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
|
||||
-DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \
|
||||
-DSDL_STATIC=OFF \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
|
||||
&& cmake --build build-sdl2-android -j"$(nproc)" \
|
||||
&& cmake --install build-sdl2-android \
|
||||
&& rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android
|
||||
|
||||
# All optional codecs disabled: Shockolate only ever calls
|
||||
# Mix_LoadWAV_RW/Mix_HookMusic (confirmed by grep - it feeds fluidsynth's
|
||||
# own PCM output through Mix_HookMusic, and never loads OGG/MOD/FLAC/MP3
|
||||
# game data), so plain WAVE support (always built in, no extra
|
||||
# dependency) is all that's needed - avoiding SDL2_mixer's vendored
|
||||
# third-party codec libraries entirely.
|
||||
RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" \
|
||||
&& tar xf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" \
|
||||
&& cmake -S "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" -B build-sdl2mixer-android \
|
||||
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
|
||||
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
|
||||
-DCMAKE_PREFIX_PATH=/opt/prebuilt/android/sdl2 \
|
||||
-DCMAKE_FIND_ROOT_PATH=/opt/prebuilt/android/sdl2 \
|
||||
-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH \
|
||||
-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH \
|
||||
-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH \
|
||||
-DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2_mixer -DBUILD_SHARED_LIBS=ON \
|
||||
-DSDL2MIXER_VENDORED=OFF -DSDL2MIXER_SAMPLES=OFF -DSDL2MIXER_CMD=OFF \
|
||||
-DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \
|
||||
-DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \
|
||||
-DSDL2MIXER_WAVPACK=OFF \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
|
||||
&& cmake --build build-sdl2mixer-android -j"$(nproc)" \
|
||||
&& cmake --install build-sdl2mixer-android \
|
||||
&& rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android
|
||||
|
||||
RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite-android \
|
||||
&& cd fluidsynth-lite-android \
|
||||
&& git checkout "${FLUIDSYNTH_LITE_REF}" \
|
||||
&& sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \
|
||||
&& sed -i 's/-Wall -Werror -std=gnu11/-Wall -std=gnu11/' CMakeLists.txt \
|
||||
&& sed -i 's/set ( LIBFLUID_LIBS m pthread )/set ( LIBFLUID_LIBS m )/' CMakeLists.txt \
|
||||
&& rm -rf .git \
|
||||
&& cd .. \
|
||||
&& cmake -S fluidsynth-lite-android -B build-fluidsynth-android \
|
||||
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
|
||||
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
|
||||
&& cmake --build build-fluidsynth-android -j"$(nproc)" \
|
||||
&& mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \
|
||||
&& cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \
|
||||
&& cp -a fluidsynth-lite-android/include/. /opt/prebuilt/android/fluidsynth-lite/include/ \
|
||||
# version.h is generated from version.h.in at configure time - fine on
|
||||
# the desktop build (in-source, so it lands right back in the source
|
||||
# tree), but this is an out-of-source build, so it only exists under
|
||||
# build-fluidsynth-android/ and must be copied in separately.
|
||||
&& cp -a build-fluidsynth-android/include/fluidsynth/version.h \
|
||||
/opt/prebuilt/android/fluidsynth-lite/include/fluidsynth/version.h \
|
||||
&& rm -rf fluidsynth-lite-android build-fluidsynth-android
|
||||
|
||||
# gl4es's own CMakeLists.txt writes its output straight to
|
||||
# ${CMAKE_SOURCE_DIR}/lib (the source tree, not the build dir) and gives
|
||||
# the GL target a ".so.1" suffix, both in filename and in its embedded
|
||||
# ELF SONAME - stage explicitly rather than `cmake --install` (which it
|
||||
# doesn't support for this target anyway). Renaming the *file* to
|
||||
# "libGL.so" isn't enough on its own: the dynamic linker resolves
|
||||
# libmain.so's NEEDED entry by the *embedded* SONAME, not by whatever
|
||||
# filename it was linked against, so a bare rename would link fine but
|
||||
# fail to dlopen on-device. And keeping the real "libGL.so.1" filename
|
||||
# isn't an option either - AGP's jniLibs packaging silently drops any
|
||||
# native library whose filename doesn't literally end in ".so". So
|
||||
# patchelf the embedded SONAME to "libGL.so" too, making the rename
|
||||
# consistent both at link time and at runtime.
|
||||
RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \
|
||||
https://github.com/ptitSeb/gl4es.git gl4es-android \
|
||||
&& rm -rf gl4es-android/.git \
|
||||
&& cmake -S gl4es-android -B build-gl4es-android \
|
||||
-DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \
|
||||
-DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \
|
||||
-DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \
|
||||
-DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \
|
||||
&& cmake --build build-gl4es-android -j"$(nproc)" \
|
||||
&& mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \
|
||||
&& cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \
|
||||
&& patchelf --set-soname libGL.so /opt/prebuilt/android/gl4es/lib/libGL.so \
|
||||
&& cp -a gl4es-android/include/. /opt/prebuilt/android/gl4es/include/ \
|
||||
&& rm -rf gl4es-android build-gl4es-android
|
||||
|
||||
COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
|
||||
COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin/build-engine.sh
|
||||
|
||||
@@ -1 +1 @@
|
||||
1
|
||||
3
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
#!/usr/bin/env bash
|
||||
# Builds the Quest APK - see build-image/prepare-android-project.sh (which
|
||||
# this calls) for how android/ gets a scratch, patched copy of engine/,
|
||||
# the Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts, and bundled
|
||||
# assets staged in. Run via ../run-image.sh (or directly, if already
|
||||
# inside this image - see the Makefile's `apk` target).
|
||||
#
|
||||
# Unlike build-engine.sh, this step needs network access: Gradle/AGP's
|
||||
# own dependency resolution isn't prebuilt into the image (see
|
||||
# build-image/Dockerfile's comment on that trade-off).
|
||||
set -euo pipefail
|
||||
|
||||
REPO_ROOT="$(pwd)"
|
||||
ANDROID_DIR="$REPO_ROOT/android"
|
||||
|
||||
bash "$REPO_ROOT/build-image/prepare-android-project.sh"
|
||||
|
||||
echo "== Determining version (shared with 'make package', see build-image/version.sh) =="
|
||||
git config --global --add safe.directory "$REPO_ROOT" 2>/dev/null || true
|
||||
VERSION="$("$REPO_ROOT/build-image/version.sh")"
|
||||
# versionName (VERSION, above) can be anything, but Android's versionCode
|
||||
# must be a positive, monotonically-increasing integer - the commit count
|
||||
# is a simple, deterministic stand-in for that.
|
||||
VERSION_CODE="$(git -C "$REPO_ROOT" rev-list --count HEAD)"
|
||||
echo "Version: $VERSION (versionCode $VERSION_CODE)"
|
||||
|
||||
echo "== Building the APK (gradlew assembleDebug) =="
|
||||
cd "$ANDROID_DIR"
|
||||
./gradlew --no-daemon assembleDebug \
|
||||
"-PquestshockVersionName=$VERSION" "-PquestshockVersionCode=$VERSION_CODE"
|
||||
|
||||
APK="$ANDROID_DIR/app/build/outputs/apk/debug/app-debug.apk"
|
||||
[ -f "$APK" ] || { echo "BUILD FAIL: $APK not found after assembleDebug" >&2; exit 1; }
|
||||
|
||||
mkdir -p "$REPO_ROOT/dist"
|
||||
PKG_NAME="questshock-$VERSION-android-arm64.apk"
|
||||
cp "$APK" "$REPO_ROOT/dist/$PKG_NAME"
|
||||
|
||||
echo "== Done =="
|
||||
echo "APK: $REPO_ROOT/dist/$PKG_NAME"
|
||||
@@ -0,0 +1,91 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prepares android/ to be built: a scratch, patched copy of engine/ (see
|
||||
# android/engine-patches/ - engine/ itself is never modified), the
|
||||
# Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es prebuilts staged into
|
||||
# jniLibs, bundled assets, and android/engine.properties (engineDir/
|
||||
# prebuiltDir, read by android/app/build.gradle). Always run inside this
|
||||
# image (via ../run-image.sh, or directly if already inside it) - it
|
||||
# reads from /opt/prebuilt/android/*, which only exists there.
|
||||
#
|
||||
# Two modes:
|
||||
# (no args) - for build-apk.sh's own gradlew, invoked in this same
|
||||
# container - engine.properties points at this
|
||||
# container's own paths (e.g. /workspace/build/...).
|
||||
# --host-paths - for building android/ natively in Android Studio on
|
||||
# the HOST afterwards, instead of via build-apk.sh.
|
||||
# Needs HOST_REPO_ROOT (set by run-image.sh) to know
|
||||
# this container's bind-mounted repo root's path on
|
||||
# the host, since a plain container-internal path
|
||||
# like /workspace/... doesn't resolve there. Also
|
||||
# exports /opt/prebuilt/android (host-invisible,
|
||||
# image-only) to build/android-prebuilt so a host-side
|
||||
# CMake configure can actually find it.
|
||||
set -euo pipefail
|
||||
|
||||
HOST_PATHS=0
|
||||
if [ "${1:-}" = "--host-paths" ]; then
|
||||
HOST_PATHS=1
|
||||
fi
|
||||
|
||||
REPO_ROOT="$(pwd)"
|
||||
ANDROID_DIR="$REPO_ROOT/android"
|
||||
SCRATCH_ENGINE="$REPO_ROOT/build/android-engine"
|
||||
PREBUILT_EXPORT_DIR="$REPO_ROOT/build/android-prebuilt"
|
||||
|
||||
echo "== Preparing a scratch, patched copy of engine/ =="
|
||||
rm -rf "$SCRATCH_ENGINE"
|
||||
mkdir -p "$(dirname "$SCRATCH_ENGINE")"
|
||||
cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE"
|
||||
for p in "$ANDROID_DIR"/engine-patches/*.patch; do
|
||||
patch -p1 -d "$SCRATCH_ENGINE" < "$p"
|
||||
done
|
||||
|
||||
echo "== Wiring up prebuilt Android SDL2_mixer/fluidsynth-lite (BUNDLED mode, like the desktop build) =="
|
||||
mkdir -p "$SCRATCH_ENGINE/build_ext/built_sdl_mixer" "$SCRATCH_ENGINE/build_ext/fluidsynth-lite"
|
||||
cp -a /opt/prebuilt/android/sdl2_mixer/. "$SCRATCH_ENGINE/build_ext/built_sdl_mixer/"
|
||||
mkdir -p "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src"
|
||||
cp -a /opt/prebuilt/android/fluidsynth-lite/lib/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/src/"
|
||||
cp -a /opt/prebuilt/android/fluidsynth-lite/include/. "$SCRATCH_ENGINE/build_ext/fluidsynth-lite/include/"
|
||||
|
||||
if [ "$HOST_PATHS" -eq 1 ]; then
|
||||
: "${HOST_REPO_ROOT:?--host-paths needs HOST_REPO_ROOT set - run via ./run-image.sh, not directly}"
|
||||
echo "== Exporting prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es for host-side use =="
|
||||
rm -rf "$PREBUILT_EXPORT_DIR"
|
||||
cp -a /opt/prebuilt/android "$PREBUILT_EXPORT_DIR"
|
||||
ENGINE_DIR_PROP="$HOST_REPO_ROOT/build/android-engine"
|
||||
PREBUILT_DIR_PROP="$HOST_REPO_ROOT/build/android-prebuilt"
|
||||
else
|
||||
ENGINE_DIR_PROP="$SCRATCH_ENGINE"
|
||||
PREBUILT_DIR_PROP="/opt/prebuilt/android"
|
||||
fi
|
||||
|
||||
{
|
||||
echo "engineDir=$ENGINE_DIR_PROP"
|
||||
echo "prebuiltDir=$PREBUILT_DIR_PROP"
|
||||
} > "$ANDROID_DIR/engine.properties"
|
||||
|
||||
echo "== Staging bundled assets (shaders, soundfont, get-assets text) =="
|
||||
ASSETS_DIR="$ANDROID_DIR/app/src/main/assets"
|
||||
rm -rf "$ASSETS_DIR"
|
||||
mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res"
|
||||
# GLES ports of engine/shaders/ (see android/gles-shaders/ and
|
||||
# android/engine-patches/04-android-opengl-es-render.patch) - not
|
||||
# engine/shaders/ itself, which is desktop-only GLSL.
|
||||
cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/"
|
||||
cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/soundfont.sf2"
|
||||
cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GET_ASSETS_QUEST.txt"
|
||||
|
||||
echo "== Staging prebuilt Android SDL2/SDL2_mixer/fluidsynth-lite/gl4es .so's into jniLibs =="
|
||||
JNI_LIBS_DIR="$ANDROID_DIR/app/src/main/jniLibs/arm64-v8a"
|
||||
rm -rf "$ANDROID_DIR/app/src/main/jniLibs"
|
||||
mkdir -p "$JNI_LIBS_DIR"
|
||||
find /opt/prebuilt/android/sdl2/lib /opt/prebuilt/android/sdl2_mixer/lib /opt/prebuilt/android/fluidsynth-lite/lib \
|
||||
/opt/prebuilt/android/gl4es/lib \
|
||||
-name '*.so' -exec cp -a {} "$JNI_LIBS_DIR/" \;
|
||||
|
||||
echo "== Done =="
|
||||
echo "engineDir=$ENGINE_DIR_PROP"
|
||||
echo "prebuiltDir=$PREBUILT_DIR_PROP"
|
||||
if [ "$HOST_PATHS" -eq 1 ]; then
|
||||
echo "android/ is ready to open and build natively in Android Studio on the host."
|
||||
fi
|
||||
@@ -0,0 +1,28 @@
|
||||
#!/usr/bin/env bash
|
||||
# Prints the release version used to name both dist/shockolate-*.tar.gz
|
||||
# (see `make package`) and dist/questshock-*.apk (see build-apk.sh), so
|
||||
# the two are always versioned identically.
|
||||
#
|
||||
# Defaults to the current git tag (vX.Y.Z, prefix stripped) if HEAD is
|
||||
# exactly on one matching that pattern; otherwise a 0.0.0-dev+<sha>
|
||||
# placeholder (with a warning on stderr) - push a vX.Y.Z tag to drive a
|
||||
# real release version. Override either way with VERSION=1.2.3 in the
|
||||
# environment.
|
||||
set -euo pipefail
|
||||
|
||||
V="${VERSION:-}"
|
||||
if [ -z "$V" ]; then
|
||||
if TAG=$(git describe --tags --exact-match --match 'v[0-9]*.[0-9]*.[0-9]*' 2>/dev/null); then
|
||||
V="${TAG#v}"
|
||||
else
|
||||
V="0.0.0-dev+$(git rev-parse --short HEAD)"
|
||||
echo "WARNING: HEAD is not on a vX.Y.Z tag - building placeholder version $V (push a tag to drive a real release version)" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
case "$V" in
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "PREFLIGHT FAIL: VERSION '$V' is not a semantic version (expected X.Y.Z, optionally with a -pre+meta suffix)" >&2; exit 1;;
|
||||
esac
|
||||
|
||||
echo "$V"
|
||||
@@ -1,16 +1,14 @@
|
||||
This package does not include System Shock's game data - it's
|
||||
copyrighted, proprietary content that can't be redistributed. To play,
|
||||
you need a copy of System Shock: Enhanced Edition (e.g. from gog.com).
|
||||
you need to own a copy of System Shock: Enhanced Edition (e.g. from
|
||||
gog.com), installed (on Windows, or via Wine/Proton on Linux).
|
||||
|
||||
From your Enhanced Edition install, you need its classic-game data and
|
||||
sound files - the res/pc/hd/data and res/pc/cdrom/data trees merged
|
||||
together (res/pc/hd's copies win the couple of filenames present in
|
||||
both: intro.res, objprop.dat), and the res/pc/hd/sound tree, packed
|
||||
inside the install's sshock.kpf (a zip file).
|
||||
|
||||
Copy that merged data into place, alongside this file:
|
||||
res/data/ <- res/pc/hd/data + res/pc/cdrom/data, merged
|
||||
res/sound/ <- res/pc/hd/sound
|
||||
Copy its res/data/ and res/sound/ folders directly into place here,
|
||||
alongside this file:
|
||||
res/data/
|
||||
res/sound/
|
||||
They already contain everything needed, in the right layout - no
|
||||
merging or extraction required.
|
||||
|
||||
Once res/data/ and res/sound/ exist next to this file, run ./run.sh from
|
||||
the root of this package to play.
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
This app does not include System Shock's game data - it's copyrighted,
|
||||
proprietary content that can't be redistributed. To play, you need to
|
||||
own a copy of System Shock: Enhanced Edition (e.g. from gog.com),
|
||||
installed (on Windows, or via Wine/Proton on Linux).
|
||||
|
||||
Using SideQuest (or any MTP file browser) with your Quest connected,
|
||||
copy its res/data/ and res/sound/ folders directly into:
|
||||
/sdcard/questshock/res/data/
|
||||
/sdcard/questshock/res/sound/
|
||||
They already contain everything needed, in the right layout - no
|
||||
merging or extraction required.
|
||||
|
||||
(shaders/ and this app's default soundfont are already here, extracted
|
||||
automatically on first launch - only res/data/ and res/sound/ are
|
||||
missing.)
|
||||
|
After Width: | Height: | Size: 493 KiB |
|
After Width: | Height: | Size: 421 KiB |
|
After Width: | Height: | Size: 434 KiB |
|
After Width: | Height: | Size: 343 KiB |
|
After Width: | Height: | Size: 272 KiB |
|
After Width: | Height: | Size: 309 KiB |
@@ -39,11 +39,17 @@ docker image inspect "$IMAGE" >/dev/null 2>&1 \
|
||||
TTY_FLAGS="-i"
|
||||
[ -t 1 ] && TTY_FLAGS="-it"
|
||||
|
||||
# HOST_REPO_ROOT: lets scripts running in the container (e.g.
|
||||
# build-image/prepare-android-project.sh --host-paths) write paths that
|
||||
# resolve on the HOST filesystem instead of this container's own
|
||||
# /workspace - needed so a gradlew/Android Studio running natively on the
|
||||
# host afterwards can find them.
|
||||
# shellcheck disable=SC2086
|
||||
docker run --rm $TTY_FLAGS \
|
||||
-v "$ROOT:/workspace" \
|
||||
-e HOST_UID="$(id -u)" \
|
||||
-e HOST_GID="$(id -g)" \
|
||||
-e HOST_REPO_ROOT="$ROOT" \
|
||||
"$IMAGE" \
|
||||
"$@"
|
||||
|
||||
|
||||