From 9e2c9ffbe655a2187f7a3173edfb278ce2bc1b39 Mon Sep 17 00:00:00 2001 From: ml Date: Mon, 20 Jul 2026 09:45:08 +0200 Subject: [PATCH] Adding first APK build --- .gitignore | 10 + ...ject-is-to-play-classic-system-shock-o.txt | 3131 +++++++++++++++++ Makefile | 19 +- README.md | 38 +- android/app/build.gradle | 88 + android/app/src/main/AndroidManifest.xml | 49 + android/app/src/main/cpp/questshock_native.c | 15 + .../ladkau/questshock/QuestShockActivity.java | 145 + .../main/java/org/libsdl/app/HIDDevice.java | 22 + .../app/HIDDeviceBLESteamController.java | 650 ++++ .../java/org/libsdl/app/HIDDeviceManager.java | 684 ++++ .../java/org/libsdl/app/HIDDeviceUSB.java | 309 ++ .../app/src/main/java/org/libsdl/app/SDL.java | 86 + .../main/java/org/libsdl/app/SDLActivity.java | 2117 +++++++++++ .../java/org/libsdl/app/SDLAudioManager.java | 514 +++ .../org/libsdl/app/SDLControllerManager.java | 854 +++++ .../main/java/org/libsdl/app/SDLSurface.java | 405 +++ .../src/main/res/mipmap-hdpi/ic_launcher.png | Bin 0 -> 12957 bytes .../src/main/res/mipmap-mdpi/ic_launcher.png | Bin 0 -> 6484 bytes .../src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 0 -> 21348 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 0 -> 44453 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 0 -> 74894 bytes android/app/src/main/res/values/strings.xml | 4 + android/build.gradle | 25 + .../01-android-shared-lib.patch | 55 + .../engine-patches/02-android-opengl-es.patch | 20 + .../03-android-gles-context.patch | 24 + .../04-android-opengl-es-render.patch | 159 + android/gles-shaders/color.frag | 13 + android/gles-shaders/main.vert | 38 + android/gles-shaders/star.frag | 22 + android/gles-shaders/texture.frag | 49 + android/gradle.properties | 2 + android/gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 54213 bytes .../gradle/wrapper/gradle-wrapper.properties | 6 + android/gradlew | 160 + android/gradlew.bat | 90 + android/settings.gradle | 2 + build-image/Dockerfile | 130 + build-image/VERSION | 2 +- build-image/build-apk.sh | 63 + res/assets/GET_ASSETS_QUEST.txt | 18 + 42 files changed, 10012 insertions(+), 6 deletions(-) create mode 100644 2026-07-19-212644-ok-this-project-is-to-play-classic-system-shock-o.txt create mode 100644 android/app/build.gradle create mode 100644 android/app/src/main/AndroidManifest.xml create mode 100644 android/app/src/main/cpp/questshock_native.c create mode 100644 android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java create mode 100644 android/app/src/main/java/org/libsdl/app/HIDDevice.java create mode 100644 android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java create mode 100644 android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java create mode 100644 android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java create mode 100644 android/app/src/main/java/org/libsdl/app/SDL.java create mode 100644 android/app/src/main/java/org/libsdl/app/SDLActivity.java create mode 100644 android/app/src/main/java/org/libsdl/app/SDLAudioManager.java create mode 100644 android/app/src/main/java/org/libsdl/app/SDLControllerManager.java create mode 100644 android/app/src/main/java/org/libsdl/app/SDLSurface.java create mode 100644 android/app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 android/app/src/main/res/values/strings.xml create mode 100644 android/build.gradle create mode 100644 android/engine-patches/01-android-shared-lib.patch create mode 100644 android/engine-patches/02-android-opengl-es.patch create mode 100644 android/engine-patches/03-android-gles-context.patch create mode 100644 android/engine-patches/04-android-opengl-es-render.patch create mode 100644 android/gles-shaders/color.frag create mode 100644 android/gles-shaders/main.vert create mode 100644 android/gles-shaders/star.frag create mode 100644 android/gles-shaders/texture.frag create mode 100644 android/gradle.properties create mode 100644 android/gradle/wrapper/gradle-wrapper.jar create mode 100644 android/gradle/wrapper/gradle-wrapper.properties create mode 100755 android/gradlew create mode 100644 android/gradlew.bat create mode 100644 android/settings.gradle create mode 100755 build-image/build-apk.sh create mode 100644 res/assets/GET_ASSETS_QUEST.txt diff --git a/.gitignore b/.gitignore index 4601cbe..c5274aa 100644 --- a/.gitignore +++ b/.gitignore @@ -17,5 +17,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 diff --git a/2026-07-19-212644-ok-this-project-is-to-play-classic-system-shock-o.txt b/2026-07-19-212644-ok-this-project-is-to-play-classic-system-shock-o.txt new file mode 100644 index 0000000..5a12dab --- /dev/null +++ b/2026-07-19-212644-ok-this-project-is-to-play-classic-system-shock-o.txt @@ -0,0 +1,3131 @@ +╭─── Claude Code v2.1.215 ─────────────────────────────────────────────────────╮ +│ │ Tips for getting │ +│ Welcome back Matthias! │ started │ +│ │ Ask Claude to create a… │ +│ ▐▛███▜▌ │ ─────────────────────── │ +│ ▝▜█████▛▘ │ What's new │ +│ ▘▘ ▝▝ │ Claude no longer runs … │ +│ Sonnet 5 · Claude Pro · │ Fixed single-segment `… │ +│ matthias.ladkau@gmail.com's Organization │ Fixed a permission-che… │ +│ ~/krotik/questshock │ /release-notes for more │ +╰──────────────────────────────────────────────────────────────────────────────╯ + + +❯ ok this project is to play classic system shock on a vr headset. the project + should be an open source project licensed under the MIT license. the base + engine should be Shockolate (https://github.com/Interrupt/systemshock) we + should store a snapshop of the engine in this repo. the building should be + done via a docker build image take inspiration from + /home/ml/krotik/deck_in_a_dash/ (please do not mention this project in the + documentation) with a build, run and upload image script. building via a + build image was already attempted here /home/ml/krotik/ss_port. It would be + good if the prerequisites for fluidsynth are already prebuild on the build + docker image i.e. ideally no download from the internet should be necessary + just the build docker image and the shockolate source code. the result of the + build image should be the system shock executable in the dist folder. the + game requires assets which should be copied from a bought version of system + shock. the installer from the enhanced edition bought from gog.com can be + found in /home/ml/krotik/questshock/res/assets/ it would be good to place a + script in the same folder which is able to extract the assets and place it in + a subfolder called ss_ee. the assets with a run script should be copied from + the res folder into the dist folder during build process (this could be done + via a Makefile or something similar if it would be more suitable). + +● I'll start by exploring the existing project structure, the previous build + attempt, and the reference Docker setup to understand what's already there + before designing this. + + Searched for 2 patterns, read 9 files, listed 1 directory, ran 1 shell command (ctrl+o to expand) + +● Now let me look at the previous ss_port build attempt in detail. + + Read 8 files, ran 6 shell commands (ctrl+o to expand) + +● Let me look at the full CMakeLists and check the actual upstream Shockolate + repo since we have internet access. + + Ran 10 shell commands (ctrl+o to expand) + +❯ source code and scripts should all go into the res folder + +● That interjection changes the layout significantly — let me confirm what you + mean before I structure the repo. + +● User answered Claude's questions: + ⎿  · You said "source code and scripts should all go into the res folder" — + how far does that extend? → Only the asset-extraction script (Recommended) + +❯ makefile or equivalent should be at the root level + +● Good, that confirms the layout I had planned — Makefile at root, only the + asset extraction script under res/assets/. Let me continue investigating the + GOG package format. + + Ran 13 shell commands (ctrl+o to expand) +● I now have a complete picture of how Shockolate builds and where its + dependencies come from. Let me set up task tracking and start building this + out. + +● API Error: 522 + {"type":"https://developers.cloudflare.com/support/troubleshooting/htt + p-status-codes/cloudflare-5xx-errors/error-522/","title":"Error 522: + Connection timed out","status":522,"detail":"Cloudflare could not + establish a TCP connection to the origin server. The TCP handshake + timed out, which may indicate the origin is overloaded, firewalling + Cloudflare, or unreachable at the network + level.","instance":"a1d967a29fc9d406","error_code":522,"error_name":"c + onnection_timeout","error_category":"origin","ray_id":"a1d967a29fc9d40 + 6","timestamp":"2026-07-19T11:32:41Z","zone":"api.anthropic.com","clou + dflare_error":true,"retryable":true,"retry_after":120,"owner_action_re + quired":true,"what_you_should_do":"**Wait and retry.** Back off for at + least 120 seconds. If the error persists, the website operator should + verify firewall rules and ensure the origin accepts connections from + Cloudflare IP ranges.","footer":"This error was generated by + Cloudflare on behalf of the website owner."}. This i… + (ctrl+o to expand) + +✻ Brewed for 11m 2s + +❯ are you still working? + +● Yes — still working through this. Quick status: I've finished investigating + (confirmed Shockolate's build system, the GOG installer's internal layout, and + how the previous ss_port attempt worked), and I'm now moving into actually + building the repo. Task 1 in progress: + + Ran 3 shell commands (ctrl+o to expand) + +● Good, clean snapshot in place, no .git remnants. Task 1 done — moving to the + Dockerfile. + + Ran 1 shell command (ctrl+o to expand) + +● Write(build-image/Dockerfile) ⎿  Wrote 91 lines to build-image/Dockerfile + 1 # Build environment for the Shockolate (System Shock) engine sna + 2 # engine/. Everything the engine build needs from the network - + 3 # SDL2_mixer, the fluidsynth-lite MIDI synth, and a General MIDI + soundfont - + with + 6 # + : the + 8 # in-container build script (build-image/build-engine.sh) copies + 9 # prebuilt pieces below straight into engine/build_ext/, matchin + 10 # engine/CMakeLists.txt's BUNDLED dependency mode expects. + … +111 lines (ctrl+o to expand) + +● Write(build-image/VERSION) + ⎿  Wrote 1 line to build-image/VERSION + 1 1 + +● Now the entrypoint and in-container build script (task 3). + +● Write(build-image/docker-entrypoint.sh) + ⎿  Wrote 14 lines to build-image/docker-entrypoint.sh + 1 #!/usr/bin/env bash + 2 # Drops from root (needed to read the prebuilt toolchain under /o + pt) to a + 3 # user matching the host's UID/GID, so anything written into the + 4 # bind-mounted repo (engine/build_ext, engine/.build-output, ...) + 5 # by the host user instead of root. + 6 set -euo pipefail + 7 + 8 USER_UID="${HOST_UID:-1000}" + 9 USER_GID="${HOST_GID:-1000}" + … +7 lines (ctrl+o to expand) + +● Write(build-image/build-engine.sh) + ⎿  Wrote 42 lines to build-image/build-engine.sh + 1 #!/usr/bin/env bash + e + 3 # dependencies prebuilt into this image at /opt/prebuilt - no ne + 4 # access needed. Run via ../run-image.sh, which mounts the repo + 5 # /workspace. + 6 # + the + 8 # shared libraries it needs at runtime (lib/), and a default MID + 9 # soundfont - everything the root Makefile needs to assemble dis + 10 set -euo pipefail + … +37 lines (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) + +● Write(build-image.sh) + ⎿  Wrote 34 lines to build-image.sh + 1 #!/usr/bin/env bash + 2 # Builds the engine build-image (build-image/Dockerfile) locally + , tagged + with + 4 # ./run-image.sh first, then publish with ./upload-image.sh. + 5 set -euo pipefail + 6 + 8 ROOT="$(pwd)" + 9 + 10 fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + … +26 lines (ctrl+o to expand) + +● Write(run-image.sh) + ⎿  Wrote 44 lines to run-image.sh + 1 #!/usr/bin/env bash + vendored + 3 # Shockolate snapshot), entirely offline - every dependency it n + 4 # (SDL2, SDL2_mixer, fluidsynth-lite, a MIDI soundfont) was alre + ady built + 6 # + 7 # Output lands in engine/.build-output/ - run `make dist` afterw + 8 # assemble it (together with the game assets from res/assets/ss_ + ee/) into + 10 # + … +40 lines (ctrl+o to expand) + +● Update(run-image.sh) + ⎿  Added 5 lines, removed 1 line + 36 docker image inspect "$IMAGE" >/dev/null 2>&1 \ + 37 || fail "$IMAGE not found locally - run ./build-image.sh fi + rst" + 38 + 39 -docker run --rm -it \ + 39 +TTY_FLAGS="-i" + 40 +[ -t 1 ] && TTY_FLAGS="-it" + 41 + + 42 +# shellcheck disable=SC2086 + 43 +docker run --rm $TTY_FLAGS \ + 44 -v "$ROOT:/workspace" \ + 45 -e HOST_UID="$(id -u)" \ + 46 -e HOST_GID="$(id -g)" \ + +● Now upload-image.sh and registry.env.example. + +● Write(upload-image.sh) + ⎿  Wrote 40 lines to upload-image.sh + 1 #!/usr/bin/env bash + 2 # Pushes the build-image - already built locally with ./build-im + 3 # and ideally verified with ./run-image.sh - to a container regi + stry, so + 4 # CI or other machines can reuse it without rebuilding the whole + 5 set -euo pipefail + 6 + 7 cd "$(dirname "${BASH_SOURCE[0]}")" + 9 + 10 fail() { echo "PREFLIGHT FAIL: $*" >&2; exit 1; } + … +36 lines (ctrl+o to expand) + +● Write(registry.env.example) + ⎿  Wrote 9 lines to registry.env.example + 1 # Copy to registry.env (gitignored, never commit the real values) + and fill + .sh, and + 3 # upload-image.sh. Only REGISTRY_IMAGE is needed for local builds + /runs; + h with + 5 # upload-image.sh. + 6 REGISTRY= + 8 REGISTRY_USER= + 9 REGISTRY_PASSWORD= + … +3 lines (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) +● Write(res/assets/extract_assets.sh) + ⎿  Wrote 93 lines to res/assets/extract_assets.sh + 1 #!/usr/bin/env bash + tem Shock: + 3 # Enhanced Edition installer from gog.com, into ./ss_ee/{data,so + und}. + 5 # Usage: + 6 # 1. Buy System Shock: Enhanced Edition on gog.com and downloa + 7 # offline installer (a Windows .exe, e.g. + 8 # setup_system_shock_enhanced_edition_1.2.16_(64bit)_(44378 + ).exe). + 10 # 3. Run this script: ./extract_assets.sh + … +104 lines (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) + +● Task 5 done. Now the root Makefile and res/run.sh (task 6). + +● Write(res/run.sh) + ⎿  Wrote 7 lines to res/run.sh + 1 #!/usr/bin/env bash + 2 # Launches System Shock. Copied next to the systemshock binary in + dist/ + 3 # by the root Makefile. + 4 set -euo pipefail + 5 cd "$(dirname "${BASH_SOURCE[0]}")" + 6 export LD_LIBRARY_PATH="$(pwd)/lib${LD_LIBRARY_PATH:+:$LD_LIBRARY + _PATH}" + 7 exec ./systemshock "$@" + +● Write(Makefile) + ⎿  Wrote 53 lines to Makefile + 1 # Assembles dist/ - a self-contained, runnable copy of System Sho + ck - + 3 # run-image.sh) and the game assets extracted from a purchased co + py (see + 4 # res/assets/extract_assets.sh). Building the engine needs the bu + 5 # (./build-image.sh, once); everything else here is plain file co + pying. + 6 + 8 ENGINE_OUT := engine/.build-output + 9 ASSETS_DIR := res/assets/ss_ee + … +53 lines (ctrl+o to expand) + + Ran 1 shell command (ctrl+o to expand) + +● Task 6 done. Now .gitignore and README.md (task 7). + + Read 1 file (ctrl+o to expand) + +● Write(.gitignore) + ⎿  Added 18 lines + 1 # Game assets are not included in the repo + 2 /res/assets/setup_system_shock_enhanced* + 3 +/res/assets/ss_ee/ + 4 + + 5 +# Build output + 6 +/dist/ + 7 + + 8 +# Engine build artifacts (engine/ is committed as a source snap + +shot; these + 9 +# are generated by run-image.sh / build-image/build-engine.sh) + 10 +/engine/build_ext/ + 11 +/engine/.build-output/ + 12 +/engine/CMakeCache.txt + 13 +/engine/CMakeFiles/ + 14 +/engine/cmake_install.cmake + 15 +/engine/Makefile + 16 +/engine/systemshock + 17 +/engine/src/Libraries/CMakeFiles/ + 18 + + 19 +# Local registry credentials (see registry.env.example) + 20 +/registry.env + +● Write(README.md) + ⎿  Wrote 72 lines to README.md + 1 # questshock + 2 + a VR + 4 headset, built on top of [Shockolate](https://github.com/Interru + pt/systemshock), + 6 + 7 ## Layout + 8 + e. Built + 10 via Docker; see below. + … +82 lines (ctrl+o to expand) + +● Now let's actually validate this end to end, starting with building the Docker + image (this will take a few minutes since it compiles + SDL2/SDL2_mixer/fluidsynth-lite). + + Ran 1 shell command (ctrl+o to expand) + +● The build image succeeded. Let me verify the soundfont actually downloaded + correctly (that URL is a bit sketchy), then run the actual engine build. + + Ran 1 shell command (ctrl+o to expand) + +● Good — soundfont downloaded properly (31MB, not an error page), and all three + prebuilt libraries look correct. Now let's compile the engine. + + Ran 1 shell command (ctrl+o to expand) + +● The engine compiled successfully offline, entirely from the prebuilt image. + Let's check ownership and the output, then run the asset extraction on the + real GOG installer. + + Ran 2 shell commands (ctrl+o to expand) +● All dynamic libraries resolve correctly with LD_LIBRARY_PATH set, no missing + libs. Now let's extract the real GOG assets and assemble dist/. + + Ran 1 shell command (ctrl+o to expand) + +● Found and need to fix a bash scoping bug: the trap ... EXIT set inside the + function fires when the shell process exits, by which point the local work + variable has already gone out of scope, causing an unbound-variable error + under set -u. Let me check what state things are in and fix it. + + Read 1 file, listed 1 directory (ctrl+o to expand) + +● Good — files ended up owned correctly (this Docker setup apparently remaps + root to the host user), so no real damage. But let's fix the trap bug properly + for portability, then verify the merge is correct against the known-good + reference. + +● Update(res/assets/extract_assets.sh) + ⎿  Added 6 lines, removed 2 lines + 42 run_extraction() { + 43 set -euo pipefail + 44 local installer="$1" out_dir="$2" + 45 - local work; work="$(mktemp -d)" + 46 - trap 'rm -rf "$work"' EXIT + 45 + # Deliberately not `local`: an EXIT trap set here fires whe + +n the whole + 46 + # shell process exits, which is after this function - and t + +his + 47 + # function's own scope - has already returned, so a `local` + + var would + 48 + # already be unset by then (unbound-variable error under `s + +et -u`). + 49 + work="$(mktemp -d)" + 50 + trap 'rm -rf "${work:-}"' EXIT + 51 + 52 echo "== Extracting sshock.kpf from installer ==" + 53 innoextract --silent --include sshock.kpf -d "$work/install + er" "$installer" + +● Now let's verify the extracted assets exactly match the known-good reference + layout from the earlier ss_port attempt. + + Ran 2 shell commands (ctrl+o to expand) + +● objprop.dat (the one file both variants should agree on if the reference + matches) confirms my "hd wins" merge precedence is correct. intro.res differs + from the old reference in all directions, which just means that reference was + assembled from a slightly different installer version — not a bug in my logic. + Now let's assemble dist/ and smoke-test the binary. + + Ran 1 shell command (ctrl+o to expand) + +● make dist succeeded end-to-end. Now let's smoke-test the actual binary + headlessly. + + Listed 1 directory, ran 1 shell command (ctrl+o to expand) + +● Found a real gap — the engine needs a shaders/ directory at runtime (relative + shaders/main.vert etc.) which I never copied into dist/. Let me check for + other runtime-relative paths I might have missed, then fix the Makefile. + + Ran 2 shell commands (ctrl+o to expand) + +● That's just a comment, not live code. Only shaders/ needs adding. Let me fix + the Makefile. + +● Update(Makefile) + ⎿  Added 1 line + 40 cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/" + 41 cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/" + 42 cp "$(ENGINE_OUT)/soundfont.sf2" "$(DIST_DIR)/res/" + 43 + cp -a engine/shaders "$(DIST_DIR)/shaders" + 44 cp -a "$(ASSETS_DIR)/data/." "$(DIST_DIR)/res/data/" + 45 cp -a "$(ASSETS_DIR)/sound/." "$(DIST_DIR)/res/sound/" + 46 cp res/run.sh "$(DIST_DIR)/run.sh" + + Ran 1 shell command (ctrl+o to expand) + +● Now let's re-run the headless smoke test to confirm OpenGL/shaders load + correctly this time. + + Ran 1 shell command (ctrl+o to expand) + +● OpenGL now initializes correctly ("OpenGL Resize 640 480") instead of falling + back to software mode, and it proceeds cleanly through splash screen setup. + The only errors are ALSA complaining about no sound card — expected in this + bare test container with no audio hardware, not a bug in dist/. This validates + the whole pipeline works end to end. + +● Let's do a final sanity check of the repo state before wrapping up. + + Ran 2 shell commands (ctrl+o to expand) + +● Clean — no leaks of the purchased installer, extracted assets, dist/, build + artifacts, or registry.env. Let me clean up the scratchpad and remove + ShockMac.sit size check just to confirm final repo size is sane. + Listed 1 directory (ctrl+o to expand) + +● One more improvement worth making: I pinned fluidsynth-lite to a floating + master ref in the Dockerfile, which undermines reproducibility. Let me fix + that to the actual commit we built against. + + Ran 1 shell command (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 1 line, removed 1 line + 14 ARG SDL2_MIXER_VERSION=2.0.4 + 15 # EtherTyper/fluidsynth-lite has no releases/tags; pin by commi + t so the image + 16 # is reproducible instead of silently picking up upstream chang + es. + 17 -ARG FLUIDSYNTH_LITE_REF=master + 17 +ARG FLUIDSYNTH_LITE_REF=c539a8d9270ba5a3f7d6e460606483fc2ab1eb6 + +1 + 18 # Soundfont used for MIDI music, matching what engine/build_dep + s.sh itself + 19 # fetches (a free substitute for the Windows default GM soundfo + nt). + 20 ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2 + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilds cleanly with the pinned commit. Everything is validated end to end. + Here's a summary of what's in place: + + Searched for 1 pattern (ctrl+o to expand) + +● Everything is built and validated. Summary: + + Repo layout + - engine/ — a clean vendored snapshot of Shockolate (Interrupt/systemshock, + current HEAD), GPLv3 license preserved as-is. + - build-image/ — Dockerfile + entrypoint + in-container build script. At + image-build time it downloads and builds SDL2 2.0.9, SDL2_mixer 2.0.4, + fluidsynth-lite (pinned to a specific commit), and fetches a MIDI soundfont — + that's the only point where internet access is needed. + - build-image.sh / run-image.sh / upload-image.sh — build the image, compile + the engine offline against it, and push to a registry. + - res/assets/extract_assets.sh — pulls the game's data/sound files out of your + GOG installer (it's an Inno Setup package; the actual game data is a zip + called sshock.kpf inside it) into res/assets/ss_ee/. + - res/run.sh + root Makefile — assembles dist/ (binary, runtime libs, shaders, + soundfont, extracted assets, launcher script). + + What I actually verified, not just wrote: + - Built the Docker image for real (SDL2/SDL2_mixer/fluidsynth-lite/soundfont + all compiled/fetched successfully). + - Compiled the engine inside it with zero network access — clean full offline + build. + - Ran the real extraction script against your actual purchased installer in + res/assets/ and diffed the result against a known-good reference layout to + confirm the hd-wins merge logic is correct. + - Ran make dist and launched the actual binary headlessly (Xvfb) — it + initializes OpenGL, loads the real extracted game data, and reaches the splash + screen with no errors (the only warnings are ALSA complaining about no sound + card in that bare test container, not a real bug). + - Caught and fixed two real bugs along the way: a missing shaders/ copy into + dist/ (silently degraded to software rendering) and a bash scoping bug in the + extraction script's cleanup trap. + + One thing worth flagging clearly: Shockolate itself is GPLv3-licensed. I kept + your MIT LICENSE for the original tooling (Docker scripts, Makefile, + extraction script) but the vendored engine/ snapshot keeps its own GPLv3 + license unchanged — I did not and could not relicense it. This is called out + in the README's License section. VR support itself isn't implemented yet — + this was purely the build/asset infrastructure the task asked for. + +✻ Brewed for 29m 2s + +※ recap: Building the questshock repo to play System Shock in VR via Shockolate, with a Docker-based build pipeline for the engine and asset extraction. Everything's built, tested, and working end to end — next step is your review before anything gets committed. (disable recaps in /config) + +❯ cool works really well. the result of a build should also be a tar.gz file + which includes all the libraries, license information and a text file in the + res folder instead of the actual assets explaining where to get the assets. + all of this should be build with a gitea action and the tar.gz file should be + uploaded to dl.ladkau.de similar to deck-in-a-dash (again please don't + mention this project in the documentation). + + Read 1 file (ctrl+o to expand) + +● This needs a few real design decisions to make CI work cleanly (avoiding + docker-in-docker), plus a redistributable "package" target. Let me implement + it. + +● Let's start with fixing the build script's path handling and the Dockerfile + marker/openssh-client together. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/build-engine.sh) + ⎿  Added 8 lines, removed 3 lines + 1 #!/usr/bin/env bash + 2 # Compiles engine/ (the vendored Shockolate snapshot) against t + he + 3 # dependencies prebuilt into this image at /opt/prebuilt - no n + etwork + 4 -# access needed. Run via ../run-image.sh, which mounts the repo + - at + 5 -# /workspace. + 4 +# access needed. Must be run with the repo root as the working + +directory + 5 +# - either via ../run-image.sh (which docker-runs this image wi + +th the + 6 +# repo bind-mounted at /workspace and WORKDIR set there), or di + +rectly + 7 +# when already inside this image (e.g. a CI job using this imag + +e as its + 8 +# container - see the Makefile's `engine` target, which picks w + +hichever + 9 +# of these applies). + 10 # + 11 # Output lands in engine/.build-output/: the systemshock binary + , the + 12 # shared libraries it needs at runtime (lib/), and a default MI + DI + 13 # soundfont - everything the root Makefile needs to assemble di + st/. + 14 set -euo pipefail + 15 + 12 -ENGINE_DIR="/workspace/engine" + 16 +REPO_ROOT="$(pwd)" + 17 +ENGINE_DIR="$REPO_ROOT/engine" + 18 OUT_DIR="$ENGINE_DIR/.build-output" + 19 + 20 cd "$ENGINE_DIR" + +● Now the Dockerfile: add the QUESTSHOCK_BUILD_IMAGE marker and openssh-client. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 2 lines, removed 1 line + 35 # backend (X11 + GL). + 36 # libogg-dev/libvorbis-dev: SDL2_mixer's OGG Vorbis music decod + er. + 37 # libasound2-dev: engine/CMakeLists.txt's optional native ALSA + MIDI output. + 38 +# openssh-client: the CI workflow's `sftp` publish step. + 39 RUN apt-get update && apt-get install -y --no-install-recommend + s \ + 40 build-essential cmake make git curl ca-certificates pkg + -config gosu \ + 41 libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxr + andr-dev \ + 42 libxi-dev libxfixes-dev libxss-dev libxinerama-dev libx + cursor-dev \ + 42 - libogg-dev libvorbis-dev libasound2-dev \ + 43 + libogg-dev libvorbis-dev libasound2-dev openssh-client + +\ + 44 && rm -rf /var/lib/apt/lists/* + 45 + 46 WORKDIR /opt/prebuilt + +● Update(build-image/Dockerfile) ⎿  Added 7 lines + 87 COPY build-image/build-engine.sh /usr/local/bin/build-engine.sh + 88 RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bin + /build-engine.sh + 89 + 90 +# Marks a shell as already running inside this image (with ever + +y engine + 91 +# build dependency prebuilt above) - lets the Makefile's `engin + +e` target + 92 +# compile directly instead of shelling out to ./run-image.sh's + +`docker + 93 +# run`, which matters for CI jobs that already run inside this + +image + 94 +# (nested docker-in-docker isn't available there). + 95 +ENV QUESTSHOCK_BUILD_IMAGE=1 + 96 + + 97 WORKDIR /workspace + 98 ENTRYPOINT ["/usr/local/bin/docker-entrypoint.sh"] + 99 CMD ["/usr/local/bin/build-engine.sh"] + +● Task 9/11 done. Now let's write the res/assets/GET_ASSETS.txt placeholder and + the NOTICE.txt/license bundling, and the package target. + +● Write(res/assets/GET_ASSETS.txt) + ⎿  Wrote 17 lines to res/assets/GET_ASSETS.txt + 1 This package does not include System Shock's game data - it's + 3 + 4 To play, you need your own purchased copy: + 5 + e + 7 offline installer. + e was + 9 built from) and, in it, drop the installer into res/assets/ a + nd run + sound + … +11 lines (ctrl+o to expand) + +● Write(NOTICE.txt) + ⎿  Wrote 9 lines to NOTICE.txt + 1 This package's original tooling (build scripts, Makefile, launche + r) is + 3 + 4 The systemshock binary and its shared libraries in lib/ are built + from + icensed + 6 under the GNU GPLv3 - see LICENSE.Shockolate. It is included unch + anged + 8 + 9 Game assets are not included - see res/GET_ASSETS.txt. + … +3 lines (ctrl+o to expand) + + Read 1 file (ctrl+o to expand) + +● Write(Makefile) + ⎿  Added 67 lines, removed 8 lines + 3 # run-image.sh) and the game assets extracted from a purchased + copy (see + 4 # res/assets/extract_assets.sh). Building the engine needs the + build-image + 5 # (./build-image.sh, once); everything else here is plain file + copying. + 6 +# + 7 +# `make package` instead builds a redistributable tarball that + +omits the + 8 +# proprietary game assets entirely (see res/assets/GET_ASSETS.t + +xt, which + 9 +# it ships in their place) - this is what CI publishes. + 10 + 11 DIST_DIR := dist + 12 ENGINE_OUT := engine/.build-output + 13 ASSETS_DIR := res/assets/ss_ee + 14 +BUILD_DIR := build + 15 +ARCH := $(shell uname -m) + 16 + 11 -.PHONY: all dist build-image engine assets clean + 17 +.PHONY: all dist build-image engine assets package clean + 18 + 19 all: dist + 20 + ... + 23 build-image: + 24 ./build-image.sh + 25 + 20 -# Compiles engine/ (the vendored Shockolate snapshot) inside th + -e + 21 -# build-image - offline, since every dependency it needs was al + -ready baked + 22 -# into the image. Always re-run so `make dist` reflects the cur + -rent + 23 -# engine/ source, same as a fresh checkout would. + 26 +# Compiles engine/ (the vendored Shockolate snapshot) - offline + +, since + 27 +# every dependency it needs was already baked into the build-im + +age. + 28 +# Always re-run so `make dist`/`make package` reflect the curre + +nt engine/ + 29 +# source, same as a fresh checkout would. + 30 +# + 31 +# If we're already running inside the build-image (QUESTSHOCK_B + +UILD_IMAGE, + 32 +# set by its Dockerfile - true for a CI job using it as its con + +tainer, + 33 +# which has no nested docker available), compile directly; othe + +rwise + 34 +# shell out to ./run-image.sh, which docker-runs the image agai + +nst this + 35 +# checkout. + 36 engine: + 25 - ./run-image.sh + 37 + @if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \ + 38 + bash build-image/build-engine.sh; \ + 39 + else \ + 40 + ./run-image.sh; \ + 41 + fi + 42 + 43 # Fails with a pointer to extract_assets.sh if the purchased ga + me assets + 44 # haven't been extracted yet. + ... + 51 + 52 dist: engine assets + 53 @echo "== Assembling $(DIST_DIR) ==" + 38 - rm -rf "$(DIST_DIR)" + 54 + rm -rf "$(DIST_DIR)/systemshock" "$(DIST_DIR)/lib" "$(DIST_DI + +R)/res" \ + 55 + "$(DIST_DIR)/shaders" "$(DIST_DIR)/run.sh" + 56 mkdir -p "$(DIST_DIR)/lib" "$(DIST_DIR)/res/data" "$(DIST_DIR + )/res/sound" + 57 cp "$(ENGINE_OUT)/systemshock" "$(DIST_DIR)/" + 58 cp -a "$(ENGINE_OUT)/lib/." "$(DIST_DIR)/lib/" + ... + 64 chmod +x "$(DIST_DIR)/run.sh" + 65 @echo "== Done - run $(DIST_DIR)/run.sh to play ==" + 66 + 67 +# Builds a versioned, redistributable Linux release tarball at + 68 +# dist/questshock--linux-.tar.gz - everything n + +eeded to + 69 +# run except the proprietary game assets (res/GET_ASSETS.txt e + +xplains how + 70 +# to get those instead of shipping res/data/, res/sound/). Ver + +sion + 71 +# defaults to the current git tag (vX.Y.Z, tag prefix stripped + +) - push a + 72 +# tag to drive a release. Override with `make package VERSION= + +1.2.3`, or + 73 +# just run it untagged for a local dev build (gets a 0.0.0-dev + ++ + 74 +# placeholder version, with a warning). + 75 +package: engine + 76 + @V="$(VERSION)"; \ + 77 + if [ -z "$$V" ]; then \ + 78 + if TAG=$$(git describe --tags --exact-match --match 'v[0-9 + +]*.[0-9]*.[0-9]*' 2>/dev/null); then \ + 79 + V=$${TAG#v}; \ + 80 + else \ + 81 + V="0.0.0-dev+$$(git rev-parse --short HEAD)"; \ + 82 + echo "WARNING: HEAD is not on a vX.Y.Z tag - building pl + +aceholder version $$V (push a tag to drive a real release vers + +ion)" >&2; \ + 83 + fi; \ + 84 + fi; \ + 85 + case "$$V" in \ + 86 + [0-9]*.[0-9]*.[0-9]*) ;; \ + 87 + *) echo "PREFLIGHT FAIL: VERSION '$$V' is not a semantic v + +ersion (expected X.Y.Z, optionally with a -pre+meta suffix)" > + +&2; exit 1;; \ + 88 + esac; \ + 89 + PKG_NAME="questshock-$$V-linux-$(ARCH)"; \ + 90 + PKG_STAGE="$(BUILD_DIR)/package/$$PKG_NAME"; \ + 91 + echo "Packaging $$PKG_NAME"; \ + 92 + rm -rf "$$PKG_STAGE"; \ + 93 + mkdir -p "$$PKG_STAGE/lib" "$$PKG_STAGE/res"; \ + 94 + cp "$(ENGINE_OUT)/systemshock" "$$PKG_STAGE/"; \ + 95 + cp -a "$(ENGINE_OUT)/lib/." "$$PKG_STAGE/lib/"; \ + 96 + cp -a engine/shaders "$$PKG_STAGE/shaders"; \ + 97 + cp "$(ENGINE_OUT)/soundfont.sf2" "$$PKG_STAGE/res/"; \ + 98 + cp res/assets/GET_ASSETS.txt "$$PKG_STAGE/res/GET_ASSETS.txt + +"; \ + 99 + cp res/run.sh "$$PKG_STAGE/run.sh"; \ + 100 + chmod +x "$$PKG_STAGE/run.sh"; \ + 101 + cp LICENSE "$$PKG_STAGE/LICENSE"; \ + 102 + cp engine/LICENSE "$$PKG_STAGE/LICENSE.Shockolate"; \ + 103 + cp NOTICE.txt "$$PKG_STAGE/NOTICE.txt"; \ + 104 + mkdir -p "$(DIST_DIR)"; \ + 105 + tar -czf "$(DIST_DIR)/$$PKG_NAME.tar.gz" -C "$(BUILD_DIR)/pa + +ckage" "$$PKG_NAME"; \ + 106 + rm -rf "$(BUILD_DIR)/package"; \ + 107 + echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz" + 108 + + 109 clean: + 51 - rm -rf "$(DIST_DIR)" "$(ENGINE_OUT)" \ + 110 + rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \ + 111 engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \ + 112 engine/cmake_install.cmake engine/Makefile engine/systemsh + ock \ + 113 engine/src/Libraries/CMakeFiles + +● Now update .gitignore for /build/. + + Read 1 file (ctrl+o to expand) + +● Update(.gitignore) + ⎿  Added 1 line + 4 + 5 # Build output + 6 /dist/ + 7 +/build/ + 8 + 9 # Engine build artifacts (engine/ is committed as a source snap + shot; these + 10 # are generated by run-image.sh / build-image/build-engine.sh) + +❯ the GET_ASSETS text file should just explain which data is needed from the + enhanced edition. It doesn't make sense if a user who wants to play the game + extracts assets from a git repository. they should just copy the files in + place. + +● Write(res/assets/GET_ASSETS.txt) + ⎿  Added 10 lines, removed 11 lines + 1 This package does not include System Shock's game data - it's + 2 -copyrighted, proprietary content that can't be redistributed. + 2 +copyrighted, proprietary content that can't be redistributed. T + +o play, + 3 +you need a copy of System Shock: Enhanced Edition (e.g. from go + +g.com). + 4 + 4 -To play, you need your own purchased copy: + 5 +From your Enhanced Edition install, you need its classic-game d + +ata and + 6 +sound files - the res/pc/hd/data and res/pc/cdrom/data trees me + +rged + 7 +together (res/pc/hd's copies win the couple of filenames presen + +t in + 8 +both: intro.res, objprop.dat), and the res/pc/hd/sound tree, pa + +cked + 9 +inside the install's sshock.kpf (a zip file). + 10 + 6 -1. Buy System Shock: Enhanced Edition on gog.com and download t + -he + 7 - offline installer. + 8 -2. Get the questshock source repository (the project this packa + -ge was + 9 - built from) and, in it, drop the installer into res/assets/ + -and run + 10 - res/assets/extract_assets.sh. That pulls the game's data and + - sound + 11 - files out of the installer into res/assets/ss_ee/. + 12 -3. Copy the contents of res/assets/ss_ee/data/ into this packag + -e's + 13 - res/data/, and res/assets/ss_ee/sound/ into this package's + 14 - res/sound/ (both alongside this file). + 11 +Copy that merged data into place, alongside this file: + 12 + res/data/ <- res/pc/hd/data + res/pc/cdrom/data, merged + 13 + res/sound/ <- res/pc/hd/sound + 14 + 15 Once res/data/ and res/sound/ exist next to this file, run ./ru + n.sh from + 16 the root of this package to play. + +● Good, that's clearer and self-contained regardless of whether the user has the + source repo. Now let's write the Gitea Actions workflow (task 12). + + Ran 1 shell command (ctrl+o to expand) + +● Write(.gitea/workflows/build.yml) + ⎿  Wrote 76 lines to .gitea/workflows/build.yml + 1 name: build + 3 # Builds a versioned Linux release tarball (see `make package`) + 4 # push/PR, plus on-demand via the Gitea "Run workflow" button. R + 5 # inside the build-image (see ../../build-image/Dockerfile, buil + 6 # via ../../build-image.sh and ../../upload-image.sh), which bun + dles + needed + ld-image + site + ich + … +79 lines (ctrl+o to expand) + +● Now let's update the README to document the new package/CI flow, then validate + everything. + + Read 1 file (ctrl+o to expand) + +● Update(README.md) + ⎿  Added 3 lines, removed 1 line + 19 below); `res/assets/extract_assets.sh` extracts it into `ss_e + e/`. + 20 - `res/run.sh` - the launcher script, copied into `dist/` on bu + ild. + 21 - `Makefile` - assembles `dist/`, a self-contained runnable cop + y of the + 22 - game, out of the compiled engine and the extracted assets. + 22 + game, out of the compiled engine and the extracted assets. Al + +so builds + 23 + `dist/questshock--linux-.tar.gz`, a redistribu + +table + 24 + package that omits the proprietary game assets (`make package + +`). + 25 + 26 ## Building + 27 + +● Update(README.md) ⎿  Added 14 lines + 56 they aren't installed locally it falls back to running the extr + action in + 57 a throwaway Docker container instead. + 58 + 59 +## Packaging + 60 + + 61 +`make package` builds `dist/questshock--linux-.t + +ar.gz`: the + 62 +compiled binary, its runtime libraries, shaders, a default MIDI + 63 +soundfont, license information, and `res/GET_ASSETS.txt` in pla + +ce of the + 64 +actual game data (which the tarball never includes). Version co + +mes from + 65 +the current git tag (push a `vX.Y.Z` tag to drive a release); w + +ithout one + 66 +it builds an untagged `0.0.0-dev+` placeholder. + 67 + + 68 +A Gitea Actions workflow (`.gitea/workflows/build.yml`) builds + +this + 69 +package on every push, using the build-image as its container ( + +so no + 70 +extra setup is needed in CI beyond the image itself), and publi + +shes the + 71 +resulting tarball to dl.ladkau.de. + 72 + + 73 ## License + 74 + 75 The original tooling in this repository (the Docker build image + , build + + Ran 1 shell command (ctrl+o to expand) + +● The image rebuild is taking a bit longer this time (probably network + variance); it's been moved to background and I'll get notified when it's done. + I'll wait for that rather than poll. + + Ran 1 shell command (ctrl+o to expand) + +● I'll wait for the background build notification rather than poll. + +✻ Crunched for 15m 36s + +● Background command "./build-image.sh 2>&1 | tail -20" completed (exit +code 0) + +● The image rebuild finished. Let me check the output and proceed with + validation. + + Read 1 file (ctrl+o to expand) + +● Image built successfully with openssh-client and the QUESTSHOCK_BUILD_IMAGE + marker. Now let's test make package via the normal docker path (host machine, + no marker set). + + Ran 1 shell command (ctrl+o to expand) + +● make package works via the docker path (host machine, no + QUESTSHOCK_BUILD_IMAGE set → used ./run-image.sh). Let's inspect the tarball + contents. + + Ran 1 shell command (ctrl+o to expand) + +● Now let's simulate the CI code path — running make package directly inside the + image (as Gitea Actions would, bypassing our custom entrypoint since Actions + container jobs override it). + + Ran 1 shell command (ctrl+o to expand) + +● Good catch on a subtle issue: git describe/git rev-parse silently failed + inside the container due to git's "dubious ownership" safety check (root + running git against files owned by a different uid), producing a malformed + version string (0.0.0-dev+ with no sha). Let me harden make package against + that. + + Read 1 file (ctrl+o to expand) + +● Update(Makefile) + ⎿  Added 1 line + 73 # just run it untagged for a local dev build (gets a 0.0.0-dev+ + + 74 # placeholder version, with a warning). + 75 package: engine + 76 + @git config --global --add safe.directory "$$(pwd)" 2>/dev/nu + +ll || true + 77 @V="$(VERSION)"; \ + 78 if [ -z "$$V" ]; then \ + 79 if TAG=$$(git describe --tags --exact-match --match 'v[0-9] + *.[0-9]*.[0-9]*' 2>/dev/null); then \ + + Ran 1 shell command (ctrl+o to expand) + +● That test left root-owned files in the user's actual repo since I bypassed the + gosu entrypoint. Let me fix that immediately with a chown before doing + anything else. + + Ran 3 shell commands (ctrl+o to expand) + +● Fixed. Let me clean up properly now and redo the CI-path simulation running as + my own user (via --user) so it can't leave root-owned files again. + + Ran 1 shell command (ctrl+o to expand) + +● Clean now. Let's re-run the CI-path simulation properly as my own uid this + time. + + Ran 1 shell command (ctrl+o to expand) +● The version string now correctly includes the sha (0.0.0-dev+3ed6400), + confirming the safe.directory fix works, and no root-owned files this time. + + Ran 1 shell command (ctrl+o to expand) + +● Let's do one more smoke test — extract the package tarball standalone and + confirm the binary launches correctly from that location (with no assets + present, as a real downloader would have). + + Ran 1 shell command (ctrl+o to expand) + +● That's the expected, correct failure mode — it clearly logs the missing + res/data/*.res files (proving run.sh/library paths are all still correct) + before segfaulting on the totally-absent game data, which is an inherent + engine limitation, not a packaging bug. A user who follows GET_ASSETS.txt and + drops in res/data/res/sound gets exactly the working setup already validated + earlier. + + Ran 1 shell command (ctrl+o to expand) + +● Everything's clean and validated. Summary of what changed: + + New make package target — builds + dist/questshock--linux-.tar.gz: the binary, its runtime .so + libraries, shaders, a MIDI soundfont, LICENSE (MIT) + LICENSE.Shockolate + (GPLv3, verbatim) + NOTICE.txt, run.sh, and res/GET_ASSETS.txt in place of the + actual game data — explaining exactly which files are needed from an Enhanced + Edition install (res/pc/hd/data + res/pc/cdrom/data merged, res/pc/hd/sound) + and where to drop them, with no dependency on this source repo's own tooling. + Version comes from git describe --tags (push a vX.Y.Z tag), falling back to a + 0.0.0-dev+ placeholder. + + CI: .gitea/workflows/build.yml runs on every push/PR using the build-image as + its container, builds the package, uploads it as a build artifact, and (on + push only) publishes the tarball via SFTP to dl.ladkau.de/files/questshock/. + + Two real bugs I found and fixed while validating, not just assumed away: + 1. The engine build script assumed it always runs via docker run with the repo + at /workspace. Gitea Actions container jobs run inside the build-image + directly (no nested docker available), so I added a QUESTSHOCK_BUILD_IMAGE + marker the Dockerfile sets, and the Makefile's engine target now compiles + directly when it detects that, instead of trying (and failing) to shell out to + run-image.sh. + 2. git describe/git rev-parse silently failed inside the container due to + git's dubious-ownership check, producing a broken version string (0.0.0-dev+ + with no sha). Fixed by registering the workspace as a safe directory in the + package recipe itself. + + I validated the whole thing three ways: the normal host→docker path, a direct + simulation of the CI code path (running as a container job would, no nested + docker), and extracting the resulting tarball standalone and confirming it + launches and correctly reports missing assets rather than doing anything + worse. + +✻ Brewed for 7m 15s + +❯ hmm, running run-image no longer produces a dist directory + + Ran 2 shell commands (ctrl+o to expand) + +● User answered Claude's questions: + ⎿  · Just so I fix the right thing: what exactly did you run? → Ran + ./run-image.sh directly + +● That's expected behavior, not a regression — run-image.sh only ever compiled + the engine into engine/.build-output/; dist/ needs make dist afterward. Let me + make that clearer by having the script say so. + + Read 1 file (ctrl+o to expand) + +● Update(run-image.sh) + ⎿  Added 7 lines + 46 -e HOST_GID="$(id -g)" \ + 47 "$IMAGE" \ + 48 "$@" + 49 + + 50 +if [ "$#" -eq 0 ]; then + 51 + echo + 52 + echo "This only compiled the engine (engine/.build-output/) + + - it does not" + 53 + echo "create dist/ by itself. Run 'make dist' next to assem + +ble a runnable" + 54 + echo "copy (or 'make package' for a redistributable tarball + +)." + 55 +fi + +● Let's verify the new hint actually shows up. + + Ran 1 shell command (ctrl+o to expand) + +● Confirmed — the hint prints correctly now. To be clear: this wasn't a + regression, ./run-image.sh was never responsible for producing dist/, only for + compiling the engine into engine/.build-output/. make dist (or make package) + is the step that assembles the runnable/distributable output, and that still + works fine (I re-ran it above). I've added a printed reminder at the end of + run-image.sh so this doesn't trip anyone up again. + +✻ Churned for 2m 55s + +❯ cool, what would be a good commit message + + Ran 2 shell commands (ctrl+o to expand) + +● Since it looks like everything's already staged, here's a message that matches + the "why"-focused, plain-sentence style of your existing commit: + + Subject: + Add Docker build pipeline, asset extraction, and CI release packaging + + Body: + Vendor the Shockolate engine and build it via a Docker image with every + dependency (SDL2, SDL2_mixer, fluidsynth-lite, a MIDI soundfont) + prebuilt, so compiling the engine needs no network access - just the + image and the engine source. Add res/assets/extract_assets.sh to pull + the game's data files out of a purchased GOG installer, and a Makefile + that assembles a runnable dist/ from the two. + + Also add `make package`, which builds a redistributable tarball that + omits the proprietary game assets (shipping res/GET_ASSETS.txt instead) + plus license information for both the MIT tooling and the GPLv3 engine, + and a Gitea Actions workflow that builds and publishes it to + dl.ladkau.de on every push. + + Want me to go ahead and commit this (as-is or tweaked), or would you rather + split it into two commits (engine/build pipeline vs. packaging/CI)? + +✻ Cogitated for 1m 4s + +❯ please do not commit - I will do commit and push +● Understood — I won't commit or push anything. Let me know if you want any + changes before you commit. + +✻ Cooked for 12s + +❯ please include the logo in the res folder as part of the readme.md file right + under the heading. running the action in gitea gives an error: Run + actions/checkout@v4 + with: + fetch-depth: 0 + OCI runtime exec failed: exec failed: unable to start container process: + exec: "node": executable file not found in $PATH + ❌ Failure - Main actions/checkout@v4 + exitcode '127': command not found, please refer to + https://github.com/nektos/act/issues/107 for more information + + + Searched for 1 pattern, read 1 file, ran 1 shell command (ctrl+o to expand) + +● Logo looks great — I'll add it under the heading. Now let's fix both things. + + Read 1 file (ctrl+o to expand) + +● Update(README.md) + ⎿  Added 2 lines + 1 # questshock + 2 + 3 +![questshock](res/logo_small.png) + 4 + + 5 An open source project to play the classic 1994 System Shock on + a VR + 6 headset, built on top of [Shockolate](https://github.com/Interru + pt/systemshock), + 7 a cross-platform port of the original game. + + Ran 1 shell command (ctrl+o to expand) + +● Now let's fix the Gitea Actions failure — actions/checkout@v4 is a + Node.js-based action, and our build-image has no Node.js installed, so it + can't run inside the container job. Let's add it. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 5 lines + 18 # Soundfont used for MIDI music, matching what engine/build_dep + s.sh itself + 19 # fetches (a free substitute for the Windows default GM soundfo + nt). + 20 ARG SOUNDFONT_URL=http://rancid.kapsi.fi/windows.sf2 + 21 +# Gitea/GitHub Actions' JS-based actions (actions/checkout, + 22 +# actions/upload-artifact, ...) need a node binary in the conta + +iner job's + 23 +# PATH - this image is otherwise pure C toolchain, so it isn't + +pulled in + 24 +# by anything else. + 25 +ARG NODE_VERSION=20.18.1 + 26 + 27 ENV DEBIAN_FRONTEND=noninteractive + 28 + +● Update(build-image/Dockerfile) + ⎿  Added 7 lines + 88 RUN mkdir -p soundfont \ + 89 && curl -sSL -o soundfont/default.sf2 "${SOUNDFONT_URL}" + 90 + 91 +# Node.js: needed only so Gitea/GitHub Actions' JS-based actio + +ns can run + 92 +# when this image is used as a CI job's container - see NODE_V + +ERSION above. + 93 +RUN curl -sSL -o /tmp/node.tar.xz \ + 94 + "https://nodejs.org/dist/v${NODE_VERSION}/node-v${NODE + +_VERSION}-linux-x64.tar.xz" \ + 95 + && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-compone + +nts=1 \ + 96 + && rm /tmp/node.tar.xz + 97 + + 98 COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-en + trypoint.sh + 99 COPY build-image/build-engine.sh /usr/local/bin/build-engine.s + h + 100 RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bi + n/build-engine.sh + + Read 1 file (ctrl+o to expand) + +● Write(build-image/VERSION) + ⎿  Added 1 line, removed 1 line + 1 -1 + 1 +2 + +● Now let's rebuild the image and verify Node.js works, plus confirm the engine + build still succeeds. + + Ran 1 shell command (ctrl+o to expand) + +❯ maybe leave the image version as 1 until we have a first successful run + +● Write(build-image/VERSION) + ⎿  Added 1 line, removed 1 line + 1 -2 + 1 +1 + +● Reverted — VERSION stays at 1. Summary of what's in place now: + + - README logo: res/logo_small.png added right under the heading. + - Dockerfile fix: added a Node.js install step (needed because + actions/checkout@v4 and actions/upload-artifact@v3 are JS-based actions, and + Gitea/act_runner executes them inside the job's container — our build-image + had no node in PATH, causing that exact error). + + Since I didn't rebuild/push, the image at + cr.ladkau.de/questshock/builder:1/:latest still lacks Node.js. When you're + ready: ./build-image.sh to rebuild locally, verify it, then ./upload-image.sh + to push — after that a re-run of the Gitea Action should get past the checkout + step. Once that's confirmed working, bump build-image/VERSION to 2 (or just + re-push over 1/latest, your call). + +✻ Sautéed for 30s + +❯ since the build image never ran successfully it should stay as version 1. + +● That makes sense — no version bump until a build with that image has actually + succeeded once. I'll keep that in mind for this project. + + Recalled 1 memory, wrote 2 memories (ctrl+o to expand) + +● Noted and saved for future reference. Version stays at 1 — you'll bump it once + a CI run actually succeeds against it. +✻ Baked for 3m 13s + +❯ now the next step is to run the game on the meta quest 2 headset. I have + installed sidequest and connected it to the quest 2 headset. I should be able + to install an apk image and copy over the assets into a folder. Most other + games which require external assets seem to require a specific root folder + like for example QuestZDoom. The folder is created on the first run of the + app where the app displays that asset files are missing. it should be + possible to build the apk locally using again the build image (this time with + a higher version) + +● This is a much bigger step than the Linux build — Shockolate has no Android + support at all right now (no NDK toolchain, no Java/SDL2 Android glue, no VR + display code), so before we can even talk about "build an APK," there's real + engine porting work involved. Let me check whether anyone's already done part + of this (similar to how QuestZDoom exists for GZDoom) before I scope this out. + +● Web Search("Shockolate System Shock Android port Quest VR") + ⎿  Did 1 search in 6s + +● Web Search("QuestZDoom how it works SDL2 Android Quest VR panel flat screen") + ⎿  Did 1 search in 10s + +● Good news and bad news from that research: + + Bad news: there's no existing Android or Quest port of Shockolate to build on. + And the closest reference point, QuestZDoom, isn't just "an APK build" — it's + a full 6-DoF VR engine port (stereo rendering, Touch controller mapping, head + tracking, teleport/smooth locomotion), which is a much bigger lift than a + Linux Docker build. Getting there from Shockolate's current desktop-only + C/SDL2 code involves several genuinely separate pieces of work: an Android NDK + cross-compile toolchain (SDL2/SDL2_mixer/fluidsynth-lite all rebuilt for + arm64-v8a), an actual Android app project (AndroidManifest.xml, Java + SDLActivity glue — none of which exist in this repo today), + Android-appropriate asset-path handling (the engine currently just does + fopen("res/data/...") relative to cwd, which doesn't work the same way in an + Android app), and — only for real VR immersion — OpenXR/Oculus Mobile SDK + integration for stereo rendering and controller input. + + Good news: what you described (APK installs, runs, detects missing assets, + creates a folder, you copy files in via SideQuest) doesn't require any of the + VR-specific work — that's exactly the pattern for getting a plain 2D Android + app running on Quest first (Quest natively runs sideloaded non-VR Android apps + as a flat floating panel in Home). That's a much smaller, achievable first + milestone, with full VR head-tracking as a distinct later phase. + +● User answered Claude's questions: + ⎿  · Given the gap between "runs on Quest" and "full VR like QuestZDoom", what + should this step actually target? → Flat 2D panel APK first (Recommended) + +● Entered plan mode + Claude is now exploring and designing an implementation approach. + +● Web Search("QuestZDoom where to copy wad files sdcard folder path SideQuest") + ⎿  Did 1 search in 8s + +● Web Search("SDL2 2.0.9 android-project template Android.mk build apk + SDL_androidmain") + ⎿  Did 1 search in 8s + +● Web Search("Quest Android app requestLegacyExternalStorage sdcard scoped + storage sideload homebrew 2024") + ⎿  Did 1 search in 13s + +● Web Search("SDL2 android ResOpenFile fopen AAssetManager relative path + fallback SDL_RWFromFile") + ⎿  Did 1 search in 9s + +❯ one more constrant - the shockolate code should not be modified but rather + pathed if it is required. this is to avoid touching the GPL code. + +❯ sorry patched not pathed + +● User answered Claude's questions: + ⎿  · The desktop build is fully offline (everything prebuilt into the image). + Android's Gradle/AGP toolchain almost always needs network access at build + time for its own dependency resolution (androidx, Gradle plugin metadata), + even with the NDK/SDL2/etc. all prebuilt into the image. Is that an + acceptable exception for the APK build specifically? → Yes, accept it for + the APK build only (Recommended) + +● Updated plan + ⎿  /plan to preview + +● User approved Claude's plan + ⎿  Plan saved to: ~/.claude/plans/wiggly-spinning-swing.md · /plan to edit + Get System Shock running on Meta Quest 2 (milestone 1: flat 2D panel) + + Context + + The user wants to sideload System Shock onto a Quest 2 via SideQuest, with + an asset folder created on first run (like QuestZDoom's /QuestZDoom/ + folder) that they populate by copying files over. Research this session + confirmed: + + - There is no existing Android or Quest port of Shockolate to build on. + - QuestZDoom, the closest reference point, is a full 6DoF VR engine port + (stereo rendering, Touch controller mapping, OpenXR) - a much bigger + scope than "build an APK". The user has confirmed (via question) that + this step targets a flat 2D panel app first - Quest natively runs a + sideloaded plain Android activity as a floating 2D panel with no VR SDK + or manifest entries required. Full 6DoF VR (OpenXR, controller input, + stereo rendering) is explicitly out of scope for this step. + - Input for this milestone: a Bluetooth mouse/keyboard connected to the + Quest (SDL2's Android backend supports HID keyboard/mouse). No + on-screen touch controls or controller mapping now. + - Hard constraint (user-stated): engine/ (the vendored GPLv3 + Shockolate source) must not be modified in place. Any Android-specific + change to its source, if one turns out to be unavoidable, must be a + patch applied at build time to a scratch copy - never a hand-edit of + the committed snapshot. + - Confirmed trade-off: unlike the fully-offline desktop build, + make apk is allowed to need network access at build time (Gradle/AGP's + own dependency resolution) - a scoped, documented exception, not a + change to the desktop build's philosophy. + - Per the saved build-image version policy: build-image/VERSION stays + at 1 until a build with the new Android toolchain actually succeeds + locally - then bump it. + + Key design decision: zero changes to engine/, via a constructor shim + + Shockolate's main() (in src/MacSrc/Shock.c) already becomes SDL2's + SDL_main automatically (SDL_main.h's macro rename, already in effect on + the desktop build too) - there is no way to run code before it without + either editing engine/ or a link-time trick. The plan uses the trick: a + new Android-only source file (android/app/src/main/cpp/android_shim.c, + NOT under engine/) defines a function marked + __attribute__((constructor)), which the C runtime guarantees runs at + shared-library load time - before main()/SDL_main() executes. That + function will: + + 1. Ensure /sdcard/questshock/ exists. + 2. Extract our own bundled build artifacts (shaders/, the soundfont, + a copy of the "get assets" instructions) from the APK's Android assets + into that folder, if not already present - mirroring make package's + approach on desktop (ship everything except the proprietary game data). + 3. chdir() into /sdcard/questshock/. + + Because Shockolate's own file I/O (ResOpenFile, fopen_caseless, plain + fopen) already uses bare relative paths like "res/data/xxx.res" + (confirmed via grep during the desktop build work), and none of it routes + through SDL_RWops (so Android's automatic asset-manager fallback for + SDL_RWFromFile doesn't apply here), this chdir alone is sufficient: if + the user copies res/data/ and res/sound/ into + /sdcard/questshock/res/, the exact same relative-path lookups that + already work in the Linux dist/ build will resolve correctly on + Android too - no engine source changes needed. Missing-asset behavior + (warn-then-crash) will be identical to what was already observed and + accepted for the Linux package tarball without assets. + + If, once building/testing, something in engine/ genuinely needs a + change (the main known risk: the desktop OpenGL shaders in + engine/shaders/*.{vert,frag} may not compile as-is under OpenGL ES, + which Android uses) - per the hard constraint, this must NOT be hand- + edited into the tracked engine/ snapshot. Instead: add .patch files + under a new android/engine-patches/ directory, and have the Android + build step apply them (patch/git apply) to a throwaway copy of + engine/ made during the build, before compiling. engine/ as committed + stays byte-for-byte the vendored upstream snapshot either way. + + What's being added + + android/ (new Gradle project, parallel to engine/, build-image/): + - Standard SDL2-for-Android skeleton: SDLActivity-based Java Activity, + AndroidManifest.xml (targetSdkVersion 29 + requestLegacyExternalStorage + - READ/WRITE_EXTERNAL_STORAGE - keeps /sdcard/questshock/ a plain, + unrestricted shared folder, avoiding Android 11+ scoped storage + entirely, matching the QuestZDoom-style precedent), build.gradle, + gradlew. + - app/src/main/cpp/CMakeLists.txt: compiles engine/src/**/*.c(c) + by reference (relative path into engine/, not copied) plus + android_shim.c, linking the prebuilt Android SDL2/SDL2_mixer/ + fluidsynth-lite from the image. Applies any android/engine-patches/ + patches to a build-time scratch copy first, if that directory is + non-empty. + - app/src/main/assets/: shaders/*, the soundfont, and a copy of a + Quest-specific "get assets" text file - bundled into the APK, extracted + to /sdcard/questshock/ on first run by android_shim.c. + - App icon derived from the existing res/logo.png/res/logo_small.png. + - Application ID: de.ladkau.questshock (matches the existing + cr.ladkau.de/dl.ladkau.de naming already used in this project; + trivial to rename later). + + build-image/Dockerfile (still version 1 until a local build + succeeds): add OpenJDK 17, the Android cmdline-tools + platform 29 + + build-tools + a pinned NDK, and cross-compile SDL2, SDL2_mixer, and + fluidsynth-lite for arm64-v8a at image-build time into + /opt/prebuilt/android/{sdl2,sdl2_mixer,fluidsynth-lite} - same + "prebuilt so the actual build needs minimal extra downloads" philosophy + as the existing desktop layer, with the one accepted exception that + Gradle itself still needs network access when ./gradlew assembleDebug + actually runs. + + build-image/build-apk.sh (parallel to build-engine.sh): runs + ./gradlew assembleDebug in android/, copies the resulting APK to + dist/. + + Makefile: new apk target, mirroring the existing engine target's + QUESTSHOCK_BUILD_IMAGE detection (compile directly if already inside the + image, else shell out via ./run-image.sh). + + README.md: document the Quest install flow (SideQuest sideload, + /sdcard/questshock/ folder, what goes in it) and make apk. + + Verification (what I can check vs. what needs the real headset) + + I can verify locally: the build-image builds with the new Android layer, + the arm64 cross-compiles of SDL2/SDL2_mixer/fluidsynth-lite succeed, and + make apk produces a real .apk with the native library and assets + correctly embedded (via unzip -l, aapt dump badging/manifest checks). + + I cannot verify actual runtime behavior - this sandboxed environment has + no GPU-accelerated Android emulator and no access to real Quest hardware. + OpenGL ES shader compatibility, the first-run folder/extraction behavior, + and actual gameplay will need to be tested by the user on their Quest 2 + via SideQuest. I'll flag remaining risk areas explicitly rather than + claim success I can't check. + + Once a local build of the new image succeeds, bump build-image/VERSION + to 2 (per the saved policy - not before). +❯ yes that is indeed acceptable - the offline build for the (core) engine + should just give a solid foundation. concern was that some dependencies might + not be reliable. + +● Good context — that confirms the scoped exception is fine. Let's start + building. First, pinning a modern SDL2 release for the Android side. + + Ran 2 shell commands (ctrl+o to expand) + +❯ the current build for linux should of course continue to work. + + Ran 4 shell commands (ctrl+o to expand) + +✻ Crunched for 35m 40s + +❯ please continue + + Searched for 1 pattern, ran 1 shell command (ctrl+o to expand) + +● Now I have a clear technical picture. Let me start implementing. First, let's + inspect the SDL2 Java glue and Activity class we'll reuse, then build out the + Android layer piece by piece. + + Read 1 file, ran 2 shell commands (ctrl+o to expand) +● Update(build-image/Dockerfile) + ⎿  Added 17 lines + 24 # by anything else. + 25 ARG NODE_VERSION=20.18.1 + 26 + 27 +# Android layer, for the Quest 2 APK build (see build-apk.sh). + +Same SDL2 + 28 +# major version as the desktop build above, but SDL2/SDL2_mixer + + are much + 29 +# newer releases here - unlike the desktop autotools build, And + +roid needs + 30 +# their real CMake+NDK build support, which only landed in late + +r + 31 +# releases. targetSdkVersion 29 (not compileSdk, which can and + +does stay + 32 +# newer) is deliberate: paired with requestLegacyExternalStorag + +e in the + 33 +# manifest, it keeps /sdcard a plain, unrestricted shared folde + +r on + 34 +# Android 10-13 devices like the Quest 2, instead of hitting An + +droid 11+ + 35 +# scoped storage. + 36 +ARG ANDROID_SDL2_VERSION=2.28.5 + 37 +ARG ANDROID_SDL2_MIXER_VERSION=2.8.0 + 38 +ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 + 39 +ARG ANDROID_PLATFORM_VERSION=29 + 40 +ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 + 41 +ARG ANDROID_NDK_VERSION=26.1.10909125 + 42 +ARG ANDROID_CMAKE_VERSION=3.22.1 + 43 + + 44 ENV DEBIAN_FRONTEND=noninteractive + 45 + 46 # build-essential/cmake/make: engine/ itself (CMake) and SDL2/S + DL2_mixer + +● Update(build-image/Dockerfile) + ⎿  Added 89 lines + 112 && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-compone + nts=1 \ + 113 && rm /tmp/node.tar.xz + 114 + 115 +# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for th + +e APK build. + 116 +# unzip: extracts the Android cmdline-tools zip below. + 117 +RUN apt-get update && apt-get install -y --no-install-recommen + +ds \ + 118 + openjdk-17-jdk-headless unzip \ + 119 + && rm -rf /var/lib/apt/lists/* + 120 + + 121 +# Android SDK/NDK: the cmdline-tools' own sdkmanager installs + +exactly the + 122 +# platform/build-tools/NDK/CMake versions the app/build.gradle + + below pins. + 123 +# gradlew (invoked by build-apk.sh) resolves its own pinned Gr + +adle version + 124 +# itself - no Gradle binary is baked into this image. Unlike t + +he manually + 125 +# cross-compiled SDL2/SDL2_mixer/fluidsynth-lite below, `./gra + +dlew + 126 +# assembleDebug` still needs network access at build time for + +Gradle/AGP's + 127 +# own dependency resolution - an accepted, deliberate exceptio + +n to this + 128 +# image's otherwise-offline build philosophy (see build-apk.sh + +). + 129 +ENV ANDROID_HOME=/opt/android-sdk \ + 130 + ANDROID_SDK_ROOT=/opt/android-sdk + 131 +ENV PATH=${ANDROID_HOME}/cmdline-tools/latest/bin:${ANDROID_HO + +ME}/platform-tools:${PATH} + 132 + + 133 +RUN mkdir -p "${ANDROID_HOME}/cmdline-tools" \ + 134 + && curl -sSL -o /tmp/cmdline-tools.zip \ + 135 + "https://dl.google.com/android/repository/commandlinet + +ools-linux-${ANDROID_CMDLINE_TOOLS_VERSION}_latest.zip" \ + 136 + && unzip -q /tmp/cmdline-tools.zip -d "${ANDROID_HOME}/cmd + +line-tools" \ + 137 + && mv "${ANDROID_HOME}/cmdline-tools/cmdline-tools" "${AND + +ROID_HOME}/cmdline-tools/latest" \ + 138 + && rm /tmp/cmdline-tools.zip \ + 139 + && yes | sdkmanager --licenses >/dev/null \ + 140 + && sdkmanager --install \ + 141 + "platform-tools" \ + 142 + "platforms;android-${ANDROID_PLATFORM_VERSION}" \ + 143 + "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \ + 144 + "ndk;${ANDROID_NDK_VERSION}" \ + 145 + "cmake;${ANDROID_CMAKE_VERSION}" + 146 + + 147 +ENV ANDROID_NDK_HOME=${ANDROID_HOME}/ndk/${ANDROID_NDK_VERSION + +} + 148 +ENV ANDROID_NDK_TOOLCHAIN=${ANDROID_NDK_HOME}/build/cmake/andr + +oid.toolchain.cmake + 149 + + 150 +# SDL2, SDL2_mixer, and fluidsynth-lite, cross-compiled for ar + +m64-v8a (the + 151 +# Quest 2's only ABI) via the NDK's CMake toolchain file, each + + installed + 152 +# to its own prefix under /opt/prebuilt/android/ - mirrors the + + desktop + 153 +# build_ext/built_sdl / built_sdl_mixer layout, just for Andro + +id. Shipped + 154 +# in the APK as separate .so's (see build-apk.sh), loaded in t + +hat order by + 155 +# QuestShockActivity.getLibraries() before the game's own libm + +ain.so. + 156 +ARG ANDROID_ABI=arm64-v8a + 157 + + 158 +RUN curl -sSLO "https://www.libsdl.org/release/SDL2-${ANDROID_ + +SDL2_VERSION}.tar.gz" \ + 159 + && tar xf "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" \ + 160 + && cmake -S "SDL2-${ANDROID_SDL2_VERSION}" -B build-sdl2-a + +ndroid \ + 161 + -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ + 162 + -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="and + +roid-${ANDROID_PLATFORM_VERSION}" \ + 163 + -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBU + +ILD_SHARED_LIBS=ON \ + 164 + -DSDL_STATIC=OFF \ + 165 + && cmake --build build-sdl2-android -j"$(nproc)" \ + 166 + && cmake --install build-sdl2-android \ + 167 + && rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_S + +DL2_VERSION}.tar.gz" build-sdl2-android + 168 + + 169 +# All optional codecs disabled: Shockolate only ever calls + 170 +# Mix_LoadWAV_RW/Mix_HookMusic (confirmed by grep - it feeds f + +luidsynth's + 171 +# own PCM output through Mix_HookMusic, and never loads OGG/MO + +D/FLAC/MP3 + 172 +# game data), so plain WAVE support (always built in, no extra + 173 +# dependency) is all that's needed - avoiding SDL2_mixer's ven + +dored + 174 +# third-party codec libraries entirely. + 175 +RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/rele + +ase/SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" \ + 176 + && tar xf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz + +" \ + 177 + && cmake -S "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" -B + +build-sdl2mixer-android \ + 178 + -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ + 179 + -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="and + +roid-${ANDROID_PLATFORM_VERSION}" \ + 180 + -DCMAKE_PREFIX_PATH=/opt/prebuilt/android/sdl2 \ + 181 + -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2_mixe + +r -DBUILD_SHARED_LIBS=ON \ + 182 + -DSDL2MIXER_VENDORED=OFF -DSDL2MIXER_SAMPLES=OFF -DSDL + +2MIXER_CMD=OFF \ + 183 + -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_M + +OD=OFF \ + 184 + -DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_O + +PUS=OFF -DSDL2MIXER_VORBIS=OFF \ + 185 + && cmake --build build-sdl2mixer-android -j"$(nproc)" \ + 186 + && cmake --install build-sdl2mixer-android \ + 187 + && rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2 + +_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-a + +ndroid + 188 + + 189 +RUN git clone https://github.com/EtherTyper/fluidsynth-lite.gi + +t fluidsynth-lite-android \ + 190 + && cd fluidsynth-lite-android \ + 191 + && git checkout "${FLUIDSYNTH_LITE_REF}" \ + 192 + && sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \ + 193 + && rm -rf .git \ + 194 + && cd .. \ + 195 + && cmake -S fluidsynth-lite-android -B build-fluidsynth-an + +droid \ + 196 + -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ + 197 + -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="and + +roid-${ANDROID_PLATFORM_VERSION}" \ + 198 + && cmake --build build-fluidsynth-android -j"$(nproc)" \ + 199 + && mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt + +/prebuilt/android/fluidsynth-lite/include \ + 200 + && cp -a build-fluidsynth-android/src/libfluidsynth.so* /o + +pt/prebuilt/android/fluidsynth-lite/lib/ \ + 201 + && cp -a fluidsynth-lite-android/include/. /opt/prebuilt/a + +ndroid/fluidsynth-lite/include/ \ + 202 + && rm -rf fluidsynth-lite-android build-fluidsynth-android + 203 + + 204 COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-en + trypoint.sh + 205 COPY build-image/build-engine.sh /usr/local/bin/build-engine.s + h + 206 RUN chmod +x /usr/local/bin/docker-entrypoint.sh /usr/local/bi + n/build-engine.sh + +❯ this should of course also run on a quest 3 and newer versions + +● Good point — since Quest 3 (and 3S/Pro) all use the same Snapdragon XR2-family + arm64-v8a chips and the same targetSdk-based legacy-storage behavior, nothing + technical changes — this design already generalizes. Let me just fix the + wording so it doesn't imply Quest 2-only. + + Searched for 1 pattern, read 1 file, ran 1 shell command (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 11 lines, removed 9 lines + 24 # by anything else. + 25 ARG NODE_VERSION=20.18.1 + 26 + 27 -# Android layer, for the Quest 2 APK build (see build-apk.sh). + -Same SDL2 + 28 -# major version as the desktop build above, but SDL2/SDL2_mixer + - are much + 29 -# newer releases here - unlike the desktop autotools build, And + -roid needs + 30 -# their real CMake+NDK build support, which only landed in late + -r + 31 -# releases. targetSdkVersion 29 (not compileSdk, which can and + -does stay + 32 -# newer) is deliberate: paired with requestLegacyExternalStorag + -e in the + 33 -# manifest, it keeps /sdcard a plain, unrestricted shared folde + -r on + 34 -# Android 10-13 devices like the Quest 2, instead of hitting An + -droid 11+ + 35 -# scoped storage. + 27 +# Android layer, for the Quest APK build (Quest 2, 3, 3S, Pro - + + all + 28 +# arm64-v8a, see build-apk.sh). Same SDL2 major version as the + +desktop + 29 +# build above, but SDL2/SDL2_mixer are much newer releases here + + - unlike + 30 +# the desktop autotools build, Android needs their real CMake+N + +DK build + 31 +# support, which only landed in later releases. targetSdkVersio + +n 29 (not + 32 +# compileSdk, which can and does stay newer) is deliberate: pai + +red with + 33 +# requestLegacyExternalStorage in the manifest, it keeps /sdcar + +d a plain, + 34 +# unrestricted shared folder instead of hitting Android 11+ sco + +ped + 35 +# storage - this is keyed off the *app's* targetSdkVersion, not + + the + 36 +# device's own Android version, so it keeps working on newer Qu + +est + 37 +# hardware/OS updates too, not just whatever's current today. + 38 ARG ANDROID_SDL2_VERSION=2.28.5 + 39 ARG ANDROID_SDL2_MIXER_VERSION=2.8.0 + 40 ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 + + Searched for 1 pattern (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 1 line, removed 1 line + 150 ENV ANDROID_NDK_TOOLCHAIN=${ANDROID_NDK_HOME}/build/cmake/andr + oid.toolchain.cmake + 151 + 152 # SDL2, SDL2_mixer, and fluidsynth-lite, cross-compiled for ar + m64-v8a (the + 153 -# Quest 2's only ABI) via the NDK's CMake toolchain file, each + - installed + 153 +# Quest's only ABI, across Quest 2/3/3S/Pro) via the NDK's CMa + +ke toolchain file, each installed + 154 # to its own prefix under /opt/prebuilt/android/ - mirrors the + desktop + 155 # build_ext/built_sdl / built_sdl_mixer layout, just for Andro + id. Shipped + 156 # in the APK as separate .so's (see build-apk.sh), loaded in t + hat order by + + Searched for 1 pattern, read 1 file, ran 1 shell command (ctrl+o to expand) + +● Let me generate the patch reliably via diff -u rather than hand-crafting it, + to avoid whitespace mismatches. + + Made 2 scratchpad edits +32 -16, read 1 file, ran 1 shell command (ctrl+o to expand) + +● Now let's generate the diff and verify it applies cleanly to a fresh copy. + + Ran 1 shell command (ctrl+o to expand) + +● Patch applies cleanly. Let's save it and clean the timestamp lines. + + Read 1 file (ctrl+o to expand) + +● Write(android/engine-patches/android-shared-lib.patch) + ⎿  Wrote 52 lines to android/engine-patches/android-shared-lib.patch + 1 --- a/CMakeLists.txt + 2 +++ b/CMakeLists.txt + 3 @@ -380,9 +380,22 @@ + 4 -include precompiled.h + 5 ) + 7 -add_executable(systemshock + 8 - ${MAC_SRC} + 9 -) + 10 +# Android has no standalone executables (Java loads a shared li + brary via + … +50 lines (ctrl+o to expand) + + Ran 2 shell commands (ctrl+o to expand) + Ran 1 shell command (ctrl+o to expand) + +● Write(android/build.gradle) + ⎿  Wrote 25 lines to android/build.gradle + 1 // Top-level Gradle build file. Adapted from SDL2's own android- + 2 // template (see app/src/main/java/org/libsdl/app/, copied from + the same + 3 // template). Unlike the C/SDL2 desktop build, this needs networ + 4 // at build time for Gradle/AGP's own dependency resolution - se + e + 6 buildscript { + 7 repositories { + 8 mavenCentral() + 10 } + … +18 lines (ctrl+o to expand) + +● Write(android/settings.gradle) + ⎿  Wrote 2 lines to android/settings.gradle + 1 rootProject.name = "questshock" + 2 include ':app' + + Read 1 file (ctrl+o to expand) + +● Write(android/gradle.properties) + ⎿  Wrote 2 lines to android/gradle.properties + 1 org.gradle.jvmargs=-Xmx1536m + 2 android.useAndroidX=true + +● Write(android/app/build.gradle) + ⎿  Wrote 63 lines to android/app/build.gradle + 1 apply plugin: 'com.android.application' + 2 + 4 // ../engine-patches/) scratch copy of engine/, written by build + -apk.sh + 5 // since it's only known at build time, not something a checked- + 6 // build.gradle can hardcode. + 7 def engineProps = new Properties() + 8 file("${projectDir}/../engine.properties").withInputStream { eng + 9 def engineDir = engineProps.getProperty('engineDir') + 10 if (engineDir == null) { + … +67 lines (ctrl+o to expand) + +● Write(android/app/src/main/AndroidManifest.xml) + ⎿  Wrote 49 lines to android/app/src/main/AndroidManifest.xml + 1 + oid" + 3 android:installLocation="auto"> + 4 + 6 + 7 + droid:required="false" /> + 9 + d:required="false" /> + … +56 lines (ctrl+o to expand) + +● Write(android/app/src/main/res/values/strings.xml) + ⎿  Wrote 4 lines to android/app/src/main/res/values/strings.xml + 1 + 2 + 3 System Shock + 4 + + Ran 2 shell commands (ctrl+o to expand) + +● Write(android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java) + ⎿  Wrote 135 lines to + android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java + 1 package de.ladkau.questshock; + 2 + 3 import android.Manifest; + 4 import android.content.pm.PackageManager; + 5 import android.content.res.AssetManager; + 6 import android.os.Bundle; + 7 import android.system.Os; + 8 import android.util.Log; + 9 import androidx.core.app.ActivityCompat; + 10 import java.io.File; + … +146 lines (ctrl+o to expand) + + Read 1 file (ctrl+o to expand) + +● Update(android/app/build.gradle) + ⎿  Added 8 lines + 61 abortOnError false + 62 } + 63 } + 64 + + 65 +dependencies { + 66 + // ActivityCompat.checkSelfPermission/requestPermissions in + 67 + // QuestShockActivity - the plain android.app.Activity APIs + + work the + 68 + // same way without androidx, but ActivityCompat is the sta + +ndard, + 69 + // documented way to request runtime permissions safely. + 70 + implementation 'androidx.core:core:1.12.0' + 71 +} + +● Write(res/assets/GET_ASSETS_QUEST.txt) + ⎿  Wrote 18 lines to res/assets/GET_ASSETS_QUEST.txt + 1 This app does not include System Shock's game data - it's copyrig + 2 proprietary content that can't be redistributed. To play, you nee + d a + 4 + 5 From your Enhanced Edition install, you need its classic-game dat + 6 sound files - the res/pc/hd/data and res/pc/cdrom/data trees merg + ed + in + 8 both: intro.res, objprop.dat), and the res/pc/hd/sound tree, pack + 9 inside the install's sshock.kpf (a zip file). + … +11 lines (ctrl+o to expand) + +● Now let's write build-apk.sh — the script that assembles everything + (scratch-patches engine/, wires up build_ext, stages APK assets, runs Gradle). + +● Write(build-image/build-apk.sh) + ⎿  Wrote 61 lines to build-image/build-apk.sh + 1 #!/usr/bin/env bash + see + 3 # android/engine-patches/ - engine/ itself is never modified) pl + 4 # Android SDL2/SDL2_mixer/fluidsynth-lite prebuilt into this ima + ge at + if + 6 # already inside this image - see the Makefile's `apk` target). + 8 # Unlike build-engine.sh, this step needs network access: Gradle + /AGP's + 10 # build-image/Dockerfile's comment on that trade-off). + … +67 lines (ctrl+o to expand) + +● Update(build-image/build-apk.sh) + ⎿  Added 1 line, removed 4 lines + 27 cp -a /opt/prebuilt/android/fluidsynth-lite/lib/. "$SCRATCH_ENG + INE/build_ext/fluidsynth-lite/src/" + 28 cp -a /opt/prebuilt/android/fluidsynth-lite/include/. "$SCRATCH + _ENGINE/build_ext/fluidsynth-lite/include/" + 29 + 30 -echo "$SCRATCH_ENGINE" > /dev/null # (documents intent; engineD + -ir is written below) + 31 -{ + 32 - echo "engineDir=$SCRATCH_ENGINE" + 33 -} > "$ANDROID_DIR/engine.properties" + 30 +echo "engineDir=$SCRATCH_ENGINE" > "$ANDROID_DIR/engine.propert + +ies" + 31 + 32 echo "== Staging bundled assets (shaders, soundfont, get-assets + text) ==" + 33 ASSETS_DIR="$ANDROID_DIR/app/src/main/assets" + Read 1 file, ran 1 shell command (ctrl+o to expand) + +● Update(Makefile) + ⎿  Added 1 line, removed 1 line + 14 BUILD_DIR := build + 15 ARCH := $(shell uname -m) + 16 + 17 -.PHONY: all dist build-image engine assets package clean + 17 +.PHONY: all dist build-image engine assets package apk clean + 18 + 19 all: dist + 20 + +❯ the build image before the android work worked btw and the assets were + produced in dl.ladkau.de + +● Great, glad the Node.js fix cleared CI end to end. That means version 1 is now + confirmed-good for its current (pre-Android) content — consistent with + keeping it at 1 while I finish this Android layer, then bumping to 2 once I've + validated the new build locally, per the saved policy. Continuing. + +● Update(Makefile) + ⎿  Added 16 lines, removed 1 line + 107 rm -rf "$(BUILD_DIR)/package"; \ + 108 echo "Wrote $(DIST_DIR)/$$PKG_NAME.tar.gz" + 109 + 110 +# Builds the Quest APK (see build-image/build-apk.sh and + 111 +# android/engine-patches/ - engine/ itself is never modified; + +a patch is + 112 +# applied to a scratch copy at build time instead). Same + 113 +# QUESTSHOCK_BUILD_IMAGE detection as `engine`. Unlike every o + +ther target + 114 +# here, this needs network access at build time (Gradle/AGP's + +own + 115 +# dependency resolution) - see build-image/Dockerfile. + 116 +apk: + 117 + @if [ -n "$$QUESTSHOCK_BUILD_IMAGE" ]; then \ + 118 + bash build-image/build-apk.sh; \ + 119 + else \ + 120 + ./run-image.sh bash build-image/build-apk.sh; \ + 121 + fi + 122 + + 123 clean: + 124 rm -rf "$(DIST_DIR)" "$(BUILD_DIR)" "$(ENGINE_OUT)" \ + 125 engine/build_ext engine/CMakeCache.txt engine/CMakeFiles \ + 126 engine/cmake_install.cmake engine/Makefile engine/systemsh + ock \ + 114 - engine/src/Libraries/CMakeFiles + 127 + engine/src/Libraries/CMakeFiles \ + 128 + android/engine.properties android/app/src/main/assets andr + +oid/app/src/main/jniLibs \ + 129 + android/app/build android/.gradle android/app/.cxx + +● Now let's update .gitignore for the new generated Android build artifacts. + + Read 1 file (ctrl+o to expand) + +● Update(.gitignore) + ⎿  Added 10 lines + 17 /engine/systemshock + 18 /engine/src/Libraries/CMakeFiles/ + 19 + 20 +# Android build artifacts (generated by build-image/build-apk.s + +h - see + 21 +# android/engine-patches/ for why engine/ itself is never touch + +ed) + 22 +/android/engine.properties + 23 +/android/app/src/main/assets/ + 24 +/android/app/src/main/jniLibs/ + 25 +/android/app/build/ + 26 +/android/.gradle/ + 27 +/android/app/.cxx/ + 28 +/android/local.properties + 29 + + 30 # Local registry credentials (see registry.env.example) + 31 /registry.env + + Read 1 file (ctrl+o to expand) + +● Update(README.md) + ⎿  Added 7 lines, removed 1 line + 23 - `Makefile` - assembles `dist/`, a self-contained runnable cop + y of the + 24 game, out of the compiled engine and the extracted assets. Al + so builds + 25 `dist/questshock--linux-.tar.gz`, a redistribu + table + 26 - package that omits the proprietary game assets (`make package + -`). + 26 + package that omits the proprietary game assets (`make package + +`), and + 27 + `dist/questshock-debug.apk` for the Quest (`make apk`). + 28 +- `android/` - the Quest app (Java `SDLActivity` glue, Gradle p + +roject). + 29 + `android/engine-patches/` holds the one small patch needed to + + build + 30 + `engine/` as an Android shared library instead of a desktop e + +xecutable + 31 + - applied to a scratch copy at build time; `engine/` itself i + +s never + 32 + modified. + 33 + 34 ## Building + 35 + +● Update(README.md) + ⎿  Added 21 lines + 78 extra setup is needed in CI beyond the image itself), and publ + ishes the + 79 resulting tarball to dl.ladkau.de. + 80 + 81 +## Playing on Meta Quest + 82 + + 83 +`make apk` builds `dist/questshock-debug.apk` - a plain (non-V + +R) Android + 84 +app that runs as a flat, floating panel in the Quest's Home en + +vironment, + 85 +same as any other sideloaded Android app. It's not a head-trac + +ked 6DoF VR + 86 +port (that's a much larger, separate undertaking); play with a + + Bluetooth + 87 +mouse/keyboard connected to the headset. + 88 + + 89 +1. Install the APK with [SideQuest](https://sidequestvr.com/) + +(or `adb + 90 + install`). + 91 +2. Launch it once. It'll ask for storage permission, then crea + +te + 92 + `/sdcard/questshock/` and extract its own bundled files (sh + +aders, a + 93 + default MIDI soundfont) there - `res/data/` and `res/sound/ + +` are + 94 + deliberately left missing, since that's the proprietary gam + +e data. + 95 +3. With the Quest connected to a PC, use SideQuest's file brow + +ser (or any + 96 + MTP file manager) to copy your own `res/data/` and `res/sou + +nd/` (see + 97 + `/sdcard/questshock/GET_ASSETS_QUEST.txt`, extracted in ste + +p 2, for + 98 + exactly what's needed and where it comes from) into + 99 + `/sdcard/questshock/res/`. + 100 +4. Launch it again. + 101 + + 102 ## License + 103 + 104 The original tooling in this repository (the Docker build imag + e, build + + Read 1 file (ctrl+o to expand) + +● Update(README.md) + ⎿  Added 7 lines, removed 2 lines + 102 ## License + 103 + 104 The original tooling in this repository (the Docker build imag + e, build + 105 -scripts, Makefile, and asset extraction script) is licensed un + -der the + 106 -[MIT License](LICENSE). + 105 +scripts, Makefile, asset extraction script, and the Quest app + +in + 106 +`android/` - aside from `org/libsdl/app/`, see below) is licen + +sed under + 107 +the [MIT License](LICENSE). + 108 + 109 +`android/app/src/main/java/org/libsdl/app/` is copied from + 110 +[SDL2](https://www.libsdl.org/)'s own android-project template + + and is + 111 +zlib-licensed, same as SDL2 itself. + 112 + + 113 The vendored engine snapshot in `engine/` is + 114 [Shockolate](https://github.com/Interrupt/systemshock), which + is licensed + 115 under the **GNU GPLv3** (see `engine/LICENSE`) - it is include + d unchanged + Ran 1 shell command (ctrl+o to expand) + +● This will take a while (Android SDK/NDK downloads + three arm64 + cross-compiles). I'll wait for it rather than poll. + ⎿  Error: `prompt` is required when `stop` is not true. + +● That tool is specific to /loop mode, not needed here — the background task + will notify me directly when it finishes. I'll wait for that. + +✻ Cooked for 22m 58s + +❯ is the build image being rebuild? + +● Yes, it's running in the background right now. Let me check how far it's + gotten. + + Read 1 file, ran 1 shell command (ctrl+o to expand) + +● Yes — it's actively building. Docker's currently running the Android SDK/NDK + setup step (I can see a container executing that stage). The full build + (SDK/NDK download + three arm64 cross-compiles: SDL2, SDL2_mixer, + fluidsynth-lite) will take a while; I'll let it run and report back once it + completes. + +✻ Cogitated for 1m 7s + +❯ for such long running downloads it would be good to have them in layers which + don't need to be regenerated every time the image needs to be rebuild + + Ran 1 shell command (ctrl+o to expand) + +● Good call — since I'll likely need to iterate on the Android layer + specifically, let me consolidate the two separate apt-get install blocks into + one at the top, so future edits to the Android portion never invalidate the + (already-validated) desktop build layers or force a redownload of everything + from scratch. This won't affect the build currently running (Docker already + snapshotted the Dockerfile when it started). + + Read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 10 lines + 60 # libogg-dev/libvorbis-dev: SDL2_mixer's OGG Vorbis music decod + er. + 61 # libasound2-dev: engine/CMakeLists.txt's optional native ALSA + MIDI output. + 62 # openssh-client: the CI workflow's `sftp` publish step. + 63 +# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for the + + APK build. + 64 +# unzip: extracts the Android cmdline-tools zip below. + 65 +# + 66 +# All apt installs deliberately live in this one RUN, first, so + + editing + 67 +# anything below it (in particular the Android cross-compile st + +eps, the + 68 +# newest and least settled part of this file) never invalidates + + Docker's + 69 +# build cache for this layer - and vice versa, adding a package + + here only + 70 +# ever costs a rebuild of the (slow) layers below it, not a re- + +download + 71 +# of packages that didn't change. + 72 RUN apt-get update && apt-get install -y --no-install-recommend + s \ + 73 build-essential cmake make git curl ca-certificates pkg + -config gosu \ + 74 libgl1-mesa-dev libglx-dev libxext-dev libx11-dev libxr + andr-dev \ + 75 libxi-dev libxfixes-dev libxss-dev libxinerama-dev libx + cursor-dev \ + 76 libogg-dev libvorbis-dev libasound2-dev openssh-client + \ + 77 + openjdk-17-jdk-headless unzip \ + 78 && rm -rf /var/lib/apt/lists/* + 79 + 80 WORKDIR /opt/prebuilt + +● Update(build-image/Dockerfile) + ⎿  Removed 6 lines + 124 && tar -xJf /tmp/node.tar.xz -C /usr/local --strip-compone + nts=1 \ + 125 && rm /tmp/node.tar.xz + 126 + 127 -# openjdk-17-jdk-headless: Gradle/AGP's own minimum JDK for th + -e APK build. + 128 -# unzip: extracts the Android cmdline-tools zip below. + 129 -RUN apt-get update && apt-get install -y --no-install-recommen + -ds \ + 130 - openjdk-17-jdk-headless unzip \ + 131 - && rm -rf /var/lib/apt/lists/* + 132 - + 127 # Android SDK/NDK: the cmdline-tools' own sdkmanager installs + exactly the + 128 # platform/build-tools/NDK/CMake versions the app/build.gradle + below pins. + 129 # gradlew (invoked by build-apk.sh) resolves its own pinned Gr + adle version + + Read 1 file (ctrl+o to expand) + +● Good — this now looks clean. Let me check on the background build's progress. + + Ran 1 shell command (ctrl+o to expand) + +● Still working through the apt-get install step (openjdk-17-jdk-headless is a + large package). I'll keep waiting for the background task to notify me when it + completes. + +✻ Baked for 3m 37s + +❯ still building? + + Ran 1 shell command (ctrl+o to expand) + +● Yes, still building — it's progressed past the apt-get step and is now in a + mkdir -... step, which matches the Android SDK cmdline-tools download/install + stage. I'll keep waiting for the completion notification. + +✻ Brewed for 45s + +※ recap: Building the questshock repo to run System Shock on Quest headsets; currently rebuilding the Docker build image with the new Android SDK/NDK layer. Once it finishes, I'll run `make apk` and validate the resulting APK. (disable recaps in /config) + +❯ still building? + Ran 3 shell commands (ctrl+o to expand) + +● Confirmed still actively working — I checked the container's process list and + network I/O directly: it's running sdkmanager --install (fetching + platform-tools, platform 29, build-tools, the NDK, and cmake), and I can see + real download traffic flowing (~670MB+ received and climbing). The NDK alone + is often 1GB+, so this step can take a while depending on connection speed. + Not stuck — I'll keep waiting for it to finish. + +✻ Sautéed for 1m 46s + +● Background command "./build-image.sh 2>&1 | tail -150" completed (exit +code 0) + +● The build-image rebuild finished successfully. Let me check the output and + then run the actual APK build. + + Read 1 file (ctrl+o to expand) + +● Found the first real bug: SDL2_mixer 2.8's CMake uses its own private + SDL2-detection module that isn't picking up our custom-installed SDL2 path. + This is the classic Android NDK toolchain gotcha — CMAKE_FIND_ROOT_PATH_MODE_* + restricts find_path/find_library to the NDK sysroot by default, excluding our + custom prefix. Let me fix it. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 4 lines + 184 -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ + 185 -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="and + roid-${ANDROID_PLATFORM_VERSION}" \ + 186 -DCMAKE_PREFIX_PATH=/opt/prebuilt/android/sdl2 \ + 187 + -DCMAKE_FIND_ROOT_PATH=/opt/prebuilt/android/sdl2 \ + 188 + -DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH \ + 189 + -DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH \ + 190 + -DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH \ + 191 -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2_mixe + r -DBUILD_SHARED_LIBS=ON \ + 192 -DSDL2MIXER_VENDORED=OFF -DSDL2MIXER_SAMPLES=OFF -DSDL + 2MIXER_CMD=OFF \ + 193 -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_M + OD=OFF \ + +● Let me proactively apply the same fix to the app's own CMake args + (Shockolate's find_package(SDL2 REQUIRED) will hit the identical restriction + otherwise). + + Read 1 file (ctrl+o to expand) + +● Update(android/app/build.gradle) + ⎿  Added 11 lines, removed 1 line + 38 // convention the desktop build already uses (p + opulated in the + 39 // scratch engine copy by build-apk.sh from + 40 // /opt/prebuilt/android/*). + 41 + // The CMAKE_FIND_ROOT_PATH* overrides below ar + +e needed + 42 + // because the NDK toolchain file restricts fin + +d_package/ + 43 + // find_path/find_library to its own sysroot by + + default, + 44 + // which would otherwise miss our custom-instal + +led SDL2 (see + 45 + // build-image/Dockerfile's SDL2_mixer build, w + +hich hit the + 46 + // exact same thing). + 47 arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=B + UNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ + 42 - "-DCMAKE_PREFIX_PATH=/opt/prebuilt/android/ + -sdl2" + 48 + "-DCMAKE_PREFIX_PATH=/opt/prebuilt/android/ + +sdl2", \ + 49 + "-DCMAKE_FIND_ROOT_PATH=/opt/prebuilt/andro + +id/sdl2", \ + 50 + "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", + + \ + 51 + "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", + + \ + 52 + "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH" + 53 abiFilters 'arm64-v8a' + 54 } + 55 } + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding — this time it should reuse the cached layers up through the SDL2 + (Android) build, and only redo SDL2_mixer onward. I'll wait for it. + + Ran 1 shell command (ctrl+o to expand) + +● That's expected — since I just merged the two apt-get layers into one, this + exact command never ran before, so Docker can't reuse cache for it (one-time + cost from the consolidation). Everything after it that's unchanged (desktop + SDL2/SDL2_mixer/fluidsynth-lite build, Node.js, Android SDK/NDK) should still + be quick since those steps are all proven already. I'll wait for it to reach + the actual fix. + ⎿  Error: `prompt` is required when `stop` is not true. + +● That tool isn't applicable here (it's /loop-specific). I'll just wait for the + background task's own completion notification instead. + +✻ Baked for 3m 49s + +❯ is it still building? + + Ran 1 shell command (ctrl+o to expand) + +● Yes, still on the consolidated apt-get install step (installing + openjdk-17-jdk-headless plus the rest, which took a while last time too). I'll + keep waiting for the completion notification. + +✻ Brewed for 34s + +❯ still building? + + Ran 1 shell command (ctrl+o to expand) + +● Progress — it's moved past the apt-get step and is now on a curl step (started + 19 seconds ago), which matches the desktop + SDL2/SDL2_mixer/fluidsynth-lite/soundfont/Node.js download sequence. Still + going, I'll keep waiting. + +✻ Baked for 33s +● Background command "./build-image.sh 2>&1 | tail -150" completed (exit +code 0) + +● The background build finished. Let's check the tail of the log to see if the + SDL2_mixer fix worked and whether the whole image build succeeded. + + Read 1 file (ctrl+o to expand) + +● Another codec I missed — WavPack. Let me check the exact option name and + disable it too. + + Searched for 2 patterns, read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 1 line + 192 -DSDL2MIXER_VENDORED=OFF -DSDL2MIXER_SAMPLES=OFF -DSDL + 2MIXER_CMD=OFF \ + 193 -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_M + OD=OFF \ + 194 -DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_O + PUS=OFF -DSDL2MIXER_VORBIS=OFF \ + 195 + -DSDL2MIXER_WAVPACK=OFF \ + 196 && cmake --build build-sdl2mixer-android -j"$(nproc)" \ + 197 && cmake --install build-sdl2mixer-android \ + 198 && rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2 + _mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-a + ndroid + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding again — this time everything up through the SDL2 (Android) build + should hit cache, so it should reach the SDL2_mixer step quickly. I'll wait + for completion. + +✻ Brewed for 1m 53s + +● Background command "./build-image.sh 2>&1 | tail -150" completed (exit +code 0) + + Read 1 file (ctrl+o to expand) + +● Fluidsynth-lite compiles fine under desktop GCC but hits two Clang-specific + warnings-as-errors (-Werror is baked into its own CMakeLists). Since we + already sed-patch this exact file (to force the shared-lib option), extending + that same approach to relax -Werror for the Android/Clang build is consistent + — and it doesn't touch engine/ at all. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 1 line + 201 && cd fluidsynth-lite-android \ + 202 && git checkout "${FLUIDSYNTH_LITE_REF}" \ + 203 && sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \ + 204 + && sed -i 's/-Wall -Werror -std=gnu11/-Wall -std=gnu11/' C + +MakeLists.txt \ + 205 && rm -rf .git \ + 206 && cd .. \ + 207 && cmake -S fluidsynth-lite-android -B build-fluidsynth-an + droid \ + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding again — should be quick now, reusing cache up through the + SDL2_mixer step. I'll wait for it. + +✻ Baked for 1m 37s + +● Background command "./build-image.sh 2>&1 | tail -150" completed (exit +code 0) + + Read 1 file (ctrl+o to expand) + +● Another Clang/NDK-specific issue: modern Android NDKs no longer ship a + libpthread.so stub (pthread is built into bionic libc directly), but + fluidsynth-lite's CMakeLists unconditionally links -lpthread. Same sed-patch + approach as before. + +● Update(build-image/Dockerfile) + ⎿  Added 1 line + 202 && git checkout "${FLUIDSYNTH_LITE_REF}" \ + 203 && sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \ + 204 && sed -i 's/-Wall -Werror -std=gnu11/-Wall -std=gnu11/' C + MakeLists.txt \ + 205 + && sed -i 's/set ( LIBFLUID_LIBS "m pthread" )/set ( LIBFL + +UID_LIBS "m" )/' CMakeLists.txt \ + 206 && rm -rf .git \ + 207 && cd .. \ + 208 && cmake -S fluidsynth-lite-android -B build-fluidsynth-an + droid \ + +● Let me verify the exact string format before rebuilding, since a sed mismatch + would silently no-op. + + Searched for 1 pattern (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 1 line, removed 1 line + 202 && git checkout "${FLUIDSYNTH_LITE_REF}" \ + 203 && sed -i 's/DLL"\ off/DLL"\ on/' CMakeLists.txt \ + 204 && sed -i 's/-Wall -Werror -std=gnu11/-Wall -std=gnu11/' C + MakeLists.txt \ + 205 - && sed -i 's/set ( LIBFLUID_LIBS "m pthread" )/set ( LIBFL + -UID_LIBS "m" )/' CMakeLists.txt \ + 205 + && sed -i 's/set ( LIBFLUID_LIBS m pthread )/set ( LIBFLUI + +D_LIBS m )/' CMakeLists.txt \ + 206 && rm -rf .git \ + 207 && cd .. \ + 208 && cmake -S fluidsynth-lite-android -B build-fluidsynth-an + droid \ + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding once more with the corrected sed pattern. Waiting for it. +✻ Cooked for 1m 54s + +● Background command "./build-image.sh 2>&1 | tail -150" completed (exit +code 0) + + Read 1 file (ctrl+o to expand) + +● The full image build succeeded — all three Android cross-compiles (SDL2, + SDL2_mixer, fluidsynth-lite) completed cleanly this time. Now let's actually + run make apk. + + Ran 2 shell commands (ctrl+o to expand) + +● Also worth fixing proactively: fluidsynth/version.h is generated by CMake at + build time (version.h.in), and since desktop does an in-source build it lands + right in the source tree, but my Android build is out-of-source — the + generated header would be missing from what I copy out. Let me fix that before + the next rebuild. + + Searched for 1 pattern, read 1 file (ctrl+o to expand) + +● Update(build-image/Dockerfile) + ⎿  Added 6 lines + 212 && mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt + /prebuilt/android/fluidsynth-lite/include \ + 213 && cp -a build-fluidsynth-android/src/libfluidsynth.so* /o + pt/prebuilt/android/fluidsynth-lite/lib/ \ + 214 && cp -a fluidsynth-lite-android/include/. /opt/prebuilt/a + ndroid/fluidsynth-lite/include/ \ + 215 + # version.h is generated from version.h.in at configure ti + +me - fine on + 216 + # the desktop build (in-source, so it lands right back in + +the source + 217 + # tree), but this is an out-of-source build, so it only ex + +ists under + 218 + # build-fluidsynth-android/ and must be copied in separate + +ly. + 219 + && cp -a build-fluidsynth-android/include/fluidsynth/versi + +on.h \ + 220 + /opt/prebuilt/android/fluidsynth-lite/include/fluidsyn + +th/version.h \ + 221 && rm -rf fluidsynth-lite-android build-fluidsynth-android + 222 + 223 COPY build-image/docker-entrypoint.sh /usr/local/bin/docker-en + trypoint.sh + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding with the version.h fix. Waiting for it. + +✻ Sautéed for 2m 33s + +● Background command "./build-image.sh 2>&1 | tail -60" completed (exit +code 0) + + Read 1 file (ctrl+o to expand) + +● Image build succeeded again. Now let's actually run make apk for real. + + Ran 1 shell command (ctrl+o to expand) + +● This is the big one — it'll patch engine/, wire up build_ext, stage + assets/jniLibs, and run ./gradlew assembleDebug (which needs network access, + as expected). Waiting for it. + +✻ Baked for 47s + +● Background command "make apk 2>&1 | tail -150" completed (exit code 0) + + Read 1 file (ctrl+o to expand) + +● Found it: AGP defaults to its own preferred NDK version (25.x) unless told + otherwise, and tries to auto-download it into the (root-owned, + non-writable-by-builder) SDK dir. Need to pin ndkVersion explicitly to match + the NDK we actually installed. + + Read 1 file (ctrl+o to expand) + +● Update(android/app/build.gradle) + ⎿  Added 4 lines + 14 android { + 15 namespace "de.ladkau.questshock" + 16 compileSdk 34 + 17 + // Must match build-image/Dockerfile's ANDROID_NDK_VERSION + +- otherwise + 18 + // AGP defaults to whatever NDK version it itself prefers a + +nd tries to + 19 + // download it into the SDK dir, which isn't writable at bu + +ild time. + 20 + ndkVersion "26.1.10909125" + 21 + 22 defaultConfig { + 23 applicationId "de.ladkau.questshock" + + Ran 1 shell command (ctrl+o to expand) + +● Waiting for this run (it'll re-download Gradle itself since the container is + ephemeral — that's a known, separate inefficiency I'll flag later, not + blocking right now). + +✻ Sautéed for 1m 29s + +● Background command "make apk 2>&1 | tail -150" completed (exit code 0) + + Read 1 file (ctrl+o to expand) + +● Same root cause again — AGP wants platforms;android-34 (matching compileSdk + 34) and its own default build-tools version (33.0.1, not the 34.0.0 I + installed), and tries to auto-fetch both into the non-writable SDK dir. Need + to install platform 34 too and pin buildToolsVersion explicitly. + + Read 1 file (ctrl+o to expand) +● Update(build-image/Dockerfile) + ⎿  Added 4 lines + 39 ARG ANDROID_SDL2_MIXER_VERSION=2.8.0 + 40 ARG ANDROID_CMDLINE_TOOLS_VERSION=11076708 + 41 ARG ANDROID_PLATFORM_VERSION=29 + 42 +# compileSdk in android/app/build.gradle - separate from the ap + +p's own + 43 +# targetSdkVersion (29, above): compileSdk is just which androi + +d.jar the + 44 +# app compiles against, and androidx.core needs a newer one tha + +n 29. + 45 +ARG ANDROID_COMPILE_SDK_VERSION=34 + 46 ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 + 47 ARG ANDROID_NDK_VERSION=26.1.10909125 + 48 ARG ANDROID_CMAKE_VERSION=3.22.1 + +● Update(build-image/Dockerfile) + ⎿  Added 1 line + 150 && sdkmanager --install \ + 151 "platform-tools" \ + 152 "platforms;android-${ANDROID_PLATFORM_VERSION}" \ + 153 + "platforms;android-${ANDROID_COMPILE_SDK_VERSION}" \ + 154 "build-tools;${ANDROID_BUILD_TOOLS_VERSION}" \ + 155 "ndk;${ANDROID_NDK_VERSION}" \ + 156 "cmake;${ANDROID_CMAKE_VERSION}" + +● Now pin buildToolsVersion explicitly in app/build.gradle to match what's + installed. + +● Update(android/app/build.gradle) + ⎿  Added 5 lines, removed 3 lines + 13 + 14 android { + 15 namespace "de.ladkau.questshock" + 16 + // compileSdk/buildToolsVersion/ndkVersion must all match w + +hat + 17 + // build-image/Dockerfile actually installs - otherwise AGP + + defaults to + 18 + // whatever version it itself prefers and tries to download + + it into the + 19 + // SDK dir at build time, which isn't writable. + 20 compileSdk 34 + 17 - // Must match build-image/Dockerfile's ANDROID_NDK_VERSION + -- otherwise + 18 - // AGP defaults to whatever NDK version it itself prefers a + -nd tries to + 19 - // download it into the SDK dir, which isn't writable at bu + -ild time. + 21 + buildToolsVersion "34.0.0" + 22 ndkVersion "26.1.10909125" + 23 + 24 defaultConfig { + + Ran 1 shell command (ctrl+o to expand) + +● Rebuilding with platform 34 added. This will cascade-rebuild the SDK/NDK layer + and everything after it. Waiting. + +✻ Worked for 2m 47s + +❯ is it still building? + + Ran 1 shell command (ctrl+o to expand) + +● Yes, still building — currently on the Android SDK/NDK install step (mkdir + -...), which now needs to fetch platform 34 in addition to what it had before. + I'll keep waiting for completion. + +✻ Churned for 42s + +● Background command "./build-image.sh 2>&1 | tail -60" completed (exit +code 0) + + Read 1 file (ctrl+o to expand) + +● Image build succeeded. Now let's try make apk again. + + Ran 1 shell command (ctrl+o to expand) + +● Waiting for this run. + +✻ Churned for 52s + +● Background command "make apk 2>&1 | tail -150" completed (exit code 0) + + Read 1 file (ctrl+o to expand) + +● Now hitting a real engine/CMakeLists.txt platform gap: find_package(OpenGL + REQUIRED) looks for desktop OpenGL (GLX), which doesn't exist on Android + (which uses GLES/EGL instead). This needs a genuine patch to + engine/CMakeLists.txt — exactly the scenario the patch mechanism was built + for. Let me look at the exact block. + + Searched for 1 pattern, read 1 file (ctrl+o to expand) + +● Let me restructure engine-patches/ to support multiple ordered patches (since + this is a second, separate concern from the shared-lib change), then add this + one. + + Made 1 scratchpad edit +15 -9, ran 3 shell commands (ctrl+o to expand) + +● Let's verify both patches apply cleanly in sequence, then save it. + + Ran 1 shell command (ctrl+o to expand) + +● Both apply cleanly in sequence. Let's save the patch and update build-apk.sh + to apply all patches in the directory. + +● Write(android/engine-patches/02-android-opengl-es.patch) + ⎿  Wrote 20 lines to android/engine-patches/02-android-opengl-es.patch + 1 --- a/CMakeLists.txt + 2 +++ b/CMakeLists.txt + 3 @@ -46,9 +46,15 @@ + 5 add_compile_options(-fsigned-char -fno-strict-aliasing) + 6 + 7 -# Find OpenGL + 8 +# Find OpenGL. Android has no desktop GL/GLX for CMake's FindOp + 9 +# to find - just link the NDK's own GLESv2/EGL libraries direct + ly. + 10 if(ENABLE_OPENGL) + … +12 lines (ctrl+o to expand) + +● Now let's verify against the actually-committed patch file (not the scratchpad + copy) and update build-apk.sh to apply all patches from the directory in + order. + + Ran 1 shell command (ctrl+o to expand) + +● Both apply cleanly from the committed files, in glob order. Now let's update + build-apk.sh to apply all patches in the directory. + + Read 1 file (ctrl+o to expand) +● Update(build-image/build-apk.sh) + ⎿  Added 3 lines, removed 1 line + 18 rm -rf "$SCRATCH_ENGINE" + 19 mkdir -p "$(dirname "$SCRATCH_ENGINE")" + 20 cp -a "$REPO_ROOT/engine" "$SCRATCH_ENGINE" + 21 -patch -p1 -d "$SCRATCH_ENGINE" < "$ANDROID_DIR/engine-patches/a + -ndroid-shared-lib.patch" + 21 +for p in "$ANDROID_DIR"/engine-patches/*.patch; do + 22 + patch -p1 -d "$SCRATCH_ENGINE" < "$p" + 23 +done + 24 + 25 echo "== Wiring up prebuilt Android SDL2_mixer/fluidsynth-lite + (BUNDLED mode, like the desktop build) ==" + 26 mkdir -p "$SCRATCH_ENGINE/build_ext/built_sdl_mixer" "$SCRATCH_ + ENGINE/build_ext/fluidsynth-lite" + +● No image rebuild needed this time — build-apk.sh and the patches run from the + bind-mounted repo, not baked into the image. Let's just re-run make apk. + + Ran 1 shell command (ctrl+o to expand) + +● Waiting for this run. + +✻ Sautéed for 4m 27s + +● Background command "make apk 2>&1 | tail -150" completed (exit code 0) + + Read 1 file (ctrl+o to expand) + +● Web Search("android.system.Os chdir method public API android") + ⎿  Did 1 search in 7s + +● Let me check directly what's actually in the API 34 stub jar, rather than + relying on possibly-wrong memory. + + Ran 1 shell command (ctrl+o to expand) + +● android.system.Os genuinely has no chdir (confirmed against the real API 34 + stub jar — Java has no public API for process-wide chdir at all). I need a + tiny bit of native code after all, but it stays entirely in android/ — never + touching engine/. Let me add it. + +● Write(android/app/src/main/cpp/questshock_native.c) + ⎿  Wrote 15 lines to android/app/src/main/cpp/questshock_native.c + 1 // android.system.Os has no public chdir() (confirmed against th + 2 // API 34 stub jar - Java has no way to change a process's worki + ng + 3 // directory at all). This is the one bit of native code QuestSh + 4 // needs, compiled into the same "main" library as engine/ (see + 5 // android/engine-patches/01-android-shared-lib.patch's ANDROID_ + 6 // - engine/ itself is never modified. + 7 #include + 8 #include + 10 JNIEXPORT void JNICALL + … +9 lines (ctrl+o to expand) + +● Now let's regenerate patch 01 to append ANDROID_EXTRA_SOURCES to the library + sources. + + Made 2 scratchpad edits +35 -16, ran 2 shell commands (ctrl+o to expand) + +● Let's verify all three patches apply cleanly in sequence and save the updated + patch 01. + + Ran 1 shell command (ctrl+o to expand) + +● Write(android/engine-patches/01-android-shared-lib.patch) + ⎿  Error writing file + + Read 1 file (ctrl+o to expand) + +● Now I can write it. + +● Write(android/engine-patches/01-android-shared-lib.patch) + ⎿  Added 7 lines, removed 4 lines + 1 --- a/CMakeLists.txt + 2 +++ b/CMakeLists.txt + 3 -@@ -380,9 +380,22 @@ + 3 +@@ -380,9 +380,25 @@ + 4 -include precompiled.h + 5 ) + 6 + ... + 11 +# JNI instead), so this is the one part of this file Android n + eeds to + 12 +# override - see android/engine-patches/. Everything else here + (sources, + 13 +# dependency discovery, link libraries) is untouched and appli + es the same + 14 -+# way on both platforms. + 14 ++# way on both platforms. ANDROID_EXTRA_SOURCES (passed in by + 15 ++# android/app/build.gradle) is QuestShockActivity's own small + +native + 16 ++# helper (android/app/src/main/cpp/) - not part of engine/ its + +elf. + 17 +if(ANDROID) + 18 + set(SHOCKOLATE_TARGET main) + 19 + add_library(${SHOCKOLATE_TARGET} SHARED + 20 + ${MAC_SRC} + 21 ++ ${ANDROID_EXTRA_SOURCES} + 22 + ) + 23 +else() + 24 + set(SHOCKOLATE_TARGET systemshock) + ... + 29 + 30 add_library(GAME_LIB ${GAME_SRC}) + 31 + 29 -@@ -391,7 +404,7 @@ + 32 +@@ -391,7 +407,7 @@ + 33 set(WINDOWS_LIBRARIES "mingw32 -mwindows") + 34 endif(MINGW) + 35 + ... + 38 ${WINDOWS_LIBRARIES} # Set it before any linker options! Bewar + e WinMain@16 error!! + 39 GAME_LIB + 40 UI_LIB + 38 -@@ -418,7 +431,10 @@ + 41 +@@ -418,7 +434,10 @@ + 42 ${ALSA_LIBRARIES} + 43 ) + 44 + + Ran 1 shell command (ctrl+o to expand) + +● Now let's update QuestShockActivity.java to load the native library early and + call nativeChdir instead of Os.chdir, and update build.gradle to pass + ANDROID_EXTRA_SOURCES. + + Read 1 file (ctrl+o to expand) + +● Update(android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java) + ⎿  Removed 1 line + 4 import android.content.pm.PackageManager; + 5 import android.content.res.AssetManager; + 6 import android.os.Bundle; + 7 -import android.system.Os; + 7 import android.util.Log; + 8 import androidx.core.app.ActivityCompat; + 9 import java.io.File; + +● Update(android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java) + ⎿  Added 16 lines + 28 private static final int PERMISSION_REQUEST_STORAGE = 1; + 29 private static final String GAME_DIR = "/sdcard/questshock" + ; + 30 + 31 + // Loaded here (rather than waiting for SDLActivity's own + 32 + // getLibraries()/loadLibraries(), which only runs from sup + +er.onCreate()) + 33 + // purely to get nativeChdir() below - Android's dynamic li + +nker resolves + 34 + // "main"'s own dependencies (SDL2, SDL2_mixer, fluidsynth) + + from the + 35 + // APK's native library directory regardless of Java-side l + +oad order, so + 36 + // loading it early here is safe. SDLActivity loading "main + +" again later + 37 + // is a harmless no-op (System.loadLibrary is idempotent pe + +r + 38 + // ClassLoader). + 39 + static { + 40 + System.loadLibrary("main"); + 41 + } + 42 + + 43 + // See android/app/src/main/cpp/questshock_native.c - andro + +id.system.Os + 44 + // has no public chdir() (confirmed against the actual API + +34 stub jar). + 45 + private static native void nativeChdir(String path); + 46 + + 47 private Bundle mSavedInstanceState; + 48 + 49 @Override + +● Update(android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java) + ⎿  Added 4 lines, removed 9 lines + 96 copyAssetFile("GET_ASSETS_QUEST.txt", marker); + 97 } + 98 + 99 - try { + 100 - // chdir() is process-wide, not per-thread - alrea + -dy in effect for + 101 - // every thread (including the one that will run S + -hockolate's own + 102 - // SDL_main) by the time super.onCreate() below lo + -ads the native + 103 - // libraries and starts it. + 104 - Os.chdir(GAME_DIR); + 105 - } catch (Exception e) { + 106 - Log.e(TAG, "chdir(" + GAME_DIR + ") failed", e); + 107 - } + 99 + // chdir() is process-wide, not per-thread - already i + +n effect for + 100 + // every thread (including the one that will run Shock + +olate's own + 101 + // SDL_main) by the time super.onCreate() below starts + + it. + 102 + nativeChdir(GAME_DIR); + 103 + 104 super.onCreate(mSavedInstanceState); + 105 } +● Now update build.gradle to pass ANDROID_EXTRA_SOURCES. + + Read 1 file (ctrl+o to expand) + +● Update(android/app/build.gradle) + ⎿  Added 2 lines, removed 1 line + 55 "-DCMAKE_FIND_ROOT_PATH=/opt/prebuilt/andro + id/sdl2", \ + 56 "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", + \ + 57 "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", + \ + 58 - "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH" + 58 + "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", + + \ + 59 + "-DANDROID_EXTRA_SOURCES=${projectDir}/src/ + +main/cpp/questshock_native.c" + 60 abiFilters 'arm64-v8a' + 61 } + 62 } + +● No image rebuild needed — all these changes live in the bind-mounted repo. + Let's re-run make apk. + + Ran 1 shell command (ctrl+o to expand) + +● Waiting for this run. + +✻ Worked for 7m 41s + +● Background command "make apk 2>&1 | tail -150" completed (exit code 0) + + Read 1 file (ctrl+o to expand) + +● This confirms the exact risk I flagged in the plan: + engine/src/MacSrc/OpenGL.cc uses desktop OpenGL headers (), which + don't exist on Android. Let me look at the actual GL usage to see how big a + patch this needs. + + Searched for 2 patterns, read 1 file (ctrl+o to expand) + +● Real progress on the build pipeline, but I've hit a genuine engine-level + compatibility gap, not just a build-config issue: OpenGL.cc mixes modern + shader-based rendering (GLES2-compatible: glCreateShader, glUniform*, etc.) + with legacy immediate-mode GL calls (glBegin/glEnd/glVertex3f, + glAlphaFunc/GL_ALPHA_TEST) that don't exist in GLES2 at all, plus a couple of + extensions not guaranteed on GLES2 (GL_CLAMP_TO_BORDER, GL_UNPACK_ROW_LENGTH). + The immediate-mode calls look like they're specifically the star-field + rendering (there's a star.frag shader and matching GL_POINTS/GL_POINT_SPRITE + usage) — a separate, older code path from the main shader-based renderer. + + Fixing the header/extension issues is mechanical (a few more lines in the same + patch). But the immediate-mode star rendering needs an actual rewrite to a + VBO+shader approach to work under GLES2 — that's real engine rendering code, + not build plumbing, and it's something I can't visually verify without a + device or working GPU-accelerated emulator (neither of which I have here). + +● User answered Claude's questions: + ⎿  · How do you want to handle the immediate-mode star rendering + (glBegin/glEnd/glVertex3f + glAlphaFunc), which needs an actual GLES2 + rewrite, not just a build-config patch? → Attempt the rewrite now + (Recommended) + + Searched for 1 pattern (ctrl+o to expand) + +● This is more extensive than just the stars — immediate-mode + glBegin/glVertex3f/glEnd is actually the primary way all textured quads get + drawn throughout this file (not just the star field), since GLES2 removes the + entire immediate-mode API. Let me read the full file to do this rewrite + properly rather than guessing at call sites. + + Read 1 file (ctrl+o to expand) + +● Before writing the replacement draw calls, I need to check main.vert — I need + to know whether it declares an explicit position attribute or relies on + desktop's fixed-function gl_Vertex builtin (which doesn't exist in GLES2 at + all). + + Read 1 file (ctrl+o to expand) + +● Now let me check the fragment shaders too, since GLES fragment shaders require + explicit precision declarations that desktop GLSL doesn't need. + + Ran 1 shell command (ctrl+o to expand) + +● Let me check where the SDL window/GL context attributes get set up, since I + may need GLES 3.0 (universally available on Quest hardware) to get + GL_UNPACK_ROW_LENGTH/GL_CLAMP_TO_BORDER support natively rather than working + around their absence in ES2. + + Searched for 1 pattern (ctrl+o to expand) + +● Targeting GLES 3.2 (Quest 2/3 hardware fully supports it) gives native + GL_UNPACK_ROW_LENGTH and GL_CLAMP_TO_BORDER support, eliminating two of the + compatibility issues entirely. Let me write the ES shader variants first. + + Ran 1 shell command (ctrl+o to expand) + +● Write(android/gles-shaders/main.vert) + ⎿  Wrote 28 lines to android/gles-shaders/main.vert + 1 #version 100 + 3 // GLES port of engine/shaders/main.vert - the one real differenc + e is + oesn't + 5 // exist in GLES at all, replaced with an explicit "position" att + 6 // (see android/engine-patches/03-android-opengl-es-render.patch' + s + to match + 8 // the vertex arrays OpenGL.cc's GLES immediate-mode emulation su + 9 precision mediump float; + … +23 lines (ctrl+o to expand) + +● Write(android/gles-shaders/color.frag) ⎿  Wrote 13 lines to android/gles-shaders/color.frag + 1 #version 100 + 2 + 3 // GLES port of engine/shaders/color.frag - identical logic, just + needs an + 4 // explicit precision declaration (required in GLES fragment shad + 5 // valid in desktop GLSL). + 6 precision mediump float; + 7 + 8 varying vec4 Color; + 9 varying float Light; + … +6 lines (ctrl+o to expand) + +● Write(android/gles-shaders/texture.frag) + ⎿  Wrote 49 lines to android/gles-shaders/texture.frag + 1 #version 100 + 3 // GLES port of engine/shaders/texture.frag - same logic, plus: + 4 // - an explicit precision declaration (required in GLES, invali + d in + 6 // - the discard at the end, replacing the fixed-function alpha + test + ed-function + 8 // pipeline at all, so this is done in the shader instead; see + 9 // android/engine-patches/03-android-opengl-es-render.patch's + unc/ + … +44 lines (ctrl+o to expand) + +● Write(android/gles-shaders/star.frag) + ⎿  Wrote 22 lines to android/gles-shaders/star.frag + 1 #version 100 + 3 // GLES port of engine/shaders/star.frag - identical logic, plus + an + 4 // explicit precision declaration (required in GLES). gl_PointCo + 5 // point-sprite rasterization for GL_POINTS both work automatica + lly in + SPRITE, + 7 // which doesn't exist in GLES at all - see + 8 // android/engine-patches/03-android-opengl-es-render.patch's + 10 precision mediump float; + … +16 lines (ctrl+o to expand) + +● Now let's write the patches. First, Shock.c's GL context attributes. + + Made 1 scratchpad edit +16 -4, read 1 file, ran 2 shell commands (ctrl+o to expand) + +● Need to fix the path prefix — let me regenerate with the correct relative path + structure. + + Ran 2 shell commands (ctrl+o to expand) + +● Now let's write the big OpenGL.cc patch — headers, disabling desktop-only + fixed-function calls, and the immediate-mode → vertex-array conversion. + + Made 1 scratchpad edit +111 -16, ran 2 shell commands (ctrl+o to expand) + +● Now let's edit CreateShader() to bind the position attribute, and + init_opengl() to skip the desktop-only fixed-function calls. + + Made 2 scratchpad edits +24 -11 (ctrl+o to expand) + +● Now let's generate the diff and verify it applies cleanly, then check it's + internally consistent (proper preprocessor balance etc.) by compiling a syntax + check if possible. + + Ran 1 shell command (ctrl+o to expand) + +● Let's save both new patches and verify all four apply together cleanly. + + Ran 2 shell commands (ctrl+o to expand) + +● Now let's update build-apk.sh to stage the ES shader variants instead of the + desktop ones. + + Read 1 file (ctrl+o to expand) + +● Update(build-image/build-apk.sh) + ⎿  Added 4 lines, removed 1 line + 35 ASSETS_DIR="$ANDROID_DIR/app/src/main/assets" + 36 rm -rf "$ASSETS_DIR" + 37 mkdir -p "$ASSETS_DIR/shaders" "$ASSETS_DIR/res" + 38 -cp -a "$REPO_ROOT/engine/shaders/." "$ASSETS_DIR/shaders/" + 38 +# GLES ports of engine/shaders/ (see android/gles-shaders/ and + 39 +# android/engine-patches/04-android-opengl-es-render.patch) - n + +ot + 40 +# engine/shaders/ itself, which is desktop-only GLSL. + 41 +cp -a "$ANDROID_DIR/gles-shaders/." "$ASSETS_DIR/shaders/" + 42 cp "/opt/prebuilt/soundfont/default.sf2" "$ASSETS_DIR/res/sound + font.sf2" + 43 cp "$REPO_ROOT/res/assets/GET_ASSETS_QUEST.txt" "$ASSETS_DIR/GE + T_ASSETS_QUEST.txt" + 44 + +● No image rebuild needed. Let's re-run make apk. + + Ran 1 shell command (ctrl+o to expand) + +● Waiting for this run. + +✻ Sautéed for 15m 29s \ No newline at end of file diff --git a/Makefile b/Makefile index 1d6a543..0b421b3 100644 --- a/Makefile +++ b/Makefile @@ -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 @@ -107,8 +107,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 diff --git a/README.md b/README.md index f27fe50..ef0835e 100644 --- a/README.md +++ b/README.md @@ -23,7 +23,13 @@ a cross-platform port of the original game. - `Makefile` - assembles `dist/`, a self-contained runnable copy of the game, out of the compiled engine and the extracted assets. Also builds `dist/questshock--linux-.tar.gz`, a redistributable - package that omits the proprietary game assets (`make package`). + package that omits the proprietary game assets (`make package`), and + `dist/questshock-debug.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 @@ -72,11 +78,37 @@ 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-debug.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. + ## 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 vendored engine snapshot in `engine/` is [Shockolate](https://github.com/Interrupt/systemshock), which is licensed diff --git a/android/app/build.gradle b/android/app/build.gradle new file mode 100644 index 0000000..1f49ac7 --- /dev/null +++ b/android/app/build.gradle @@ -0,0 +1,88 @@ +apply plugin: 'com.android.application' + +// Path to the (patched, at build time - see build-apk.sh and +// ../engine-patches/) scratch copy of engine/, written by build-apk.sh +// since it's only known at build time, not something a checked-in +// build.gradle can hardcode. +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, not gradlew directly") +} + +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 1 + versionName "1.0" + + 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 build-apk.sh from + // /opt/prebuilt/android/*). + // 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). + arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ + "-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", \ + "-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' +} diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..97ee083 --- /dev/null +++ b/android/app/src/main/AndroidManifest.xml @@ -0,0 +1,49 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/android/app/src/main/cpp/questshock_native.c b/android/app/src/main/cpp/questshock_native.c new file mode 100644 index 0000000..26dd92a --- /dev/null +++ b/android/app/src/main/cpp/questshock_native.c @@ -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 +#include + +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); +} diff --git a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java new file mode 100644 index 0000000..e81cf2a --- /dev/null +++ b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java @@ -0,0 +1,145 @@ +package de.ladkau.questshock; + +import android.Manifest; +import android.content.pm.PackageManager; +import android.content.res.AssetManager; +import android.os.Bundle; +import android.util.Log; +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; + +/** + * Everything here runs before super.onCreate() (which is what loads the + * native libraries and eventually calls Shockolate's own main()) - see + * android/engine-patches/ for why: engine/ itself is never modified, so + * this Java-side setup is the only place left to prepare + * /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. + */ +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); + + private Bundle mSavedInstanceState; + + @Override + protected String[] getLibraries() { + return new String[] { + "SDL2", + "SDL2_mixer", + "fluidsynth", + "main" + }; + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + mSavedInstanceState = savedInstanceState; + 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); + } + + // 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 super.onCreate() below starts it. + nativeChdir(GAME_DIR); + + super.onCreate(mSavedInstanceState); + } + + 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)); + } + } +} diff --git a/android/app/src/main/java/org/libsdl/app/HIDDevice.java b/android/app/src/main/java/org/libsdl/app/HIDDevice.java new file mode 100644 index 0000000..955df5d --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDevice.java @@ -0,0 +1,22 @@ +package org.libsdl.app; + +import android.hardware.usb.UsbDevice; + +interface HIDDevice +{ + public int getId(); + public int getVendorId(); + public int getProductId(); + public String getSerialNumber(); + public int getVersion(); + public String getManufacturerName(); + public String getProductName(); + public UsbDevice getDevice(); + public boolean open(); + public int sendFeatureReport(byte[] report); + public int sendOutputReport(byte[] report); + public boolean getFeatureReport(byte[] report); + public void setFrozen(boolean frozen); + public void close(); + public void shutdown(); +} diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java new file mode 100644 index 0000000..ee5521f --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceBLESteamController.java @@ -0,0 +1,650 @@ +package org.libsdl.app; + +import android.content.Context; +import android.bluetooth.BluetoothDevice; +import android.bluetooth.BluetoothGatt; +import android.bluetooth.BluetoothGattCallback; +import android.bluetooth.BluetoothGattCharacteristic; +import android.bluetooth.BluetoothGattDescriptor; +import android.bluetooth.BluetoothManager; +import android.bluetooth.BluetoothProfile; +import android.bluetooth.BluetoothGattService; +import android.hardware.usb.UsbDevice; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.os.*; + +//import com.android.internal.util.HexDump; + +import java.lang.Runnable; +import java.util.Arrays; +import java.util.LinkedList; +import java.util.UUID; + +class HIDDeviceBLESteamController extends BluetoothGattCallback implements HIDDevice { + + private static final String TAG = "hidapi"; + private HIDDeviceManager mManager; + private BluetoothDevice mDevice; + private int mDeviceId; + private BluetoothGatt mGatt; + private boolean mIsRegistered = false; + private boolean mIsConnected = false; + private boolean mIsChromebook = false; + private boolean mIsReconnecting = false; + private boolean mFrozen = false; + private LinkedList mOperations; + GattOperation mCurrentOperation = null; + private Handler mHandler; + + private static final int TRANSPORT_AUTO = 0; + private static final int TRANSPORT_BREDR = 1; + private static final int TRANSPORT_LE = 2; + + private static final int CHROMEBOOK_CONNECTION_CHECK_INTERVAL = 10000; + + static public final UUID steamControllerService = UUID.fromString("100F6C32-1735-4313-B402-38567131E5F3"); + static public final UUID inputCharacteristic = UUID.fromString("100F6C33-1735-4313-B402-38567131E5F3"); + static public final UUID reportCharacteristic = UUID.fromString("100F6C34-1735-4313-B402-38567131E5F3"); + static private final byte[] enterValveMode = new byte[] { (byte)0xC0, (byte)0x87, 0x03, 0x08, 0x07, 0x00 }; + + static class GattOperation { + private enum Operation { + CHR_READ, + CHR_WRITE, + ENABLE_NOTIFICATION + } + + Operation mOp; + UUID mUuid; + byte[] mValue; + BluetoothGatt mGatt; + boolean mResult = true; + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + } + + private GattOperation(BluetoothGatt gatt, GattOperation.Operation operation, UUID uuid, byte[] value) { + mGatt = gatt; + mOp = operation; + mUuid = uuid; + mValue = value; + } + + public void run() { + // This is executed in main thread + BluetoothGattCharacteristic chr; + + switch (mOp) { + case CHR_READ: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Reading characteristic " + chr.getUuid()); + if (!mGatt.readCharacteristic(chr)) { + Log.e(TAG, "Unable to read characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case CHR_WRITE: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing characteristic " + chr.getUuid() + " value=" + HexDump.toHexString(value)); + chr.setValue(mValue); + if (!mGatt.writeCharacteristic(chr)) { + Log.e(TAG, "Unable to write characteristic " + mUuid.toString()); + mResult = false; + break; + } + mResult = true; + break; + case ENABLE_NOTIFICATION: + chr = getCharacteristic(mUuid); + //Log.v(TAG, "Writing descriptor of " + chr.getUuid()); + if (chr != null) { + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + int properties = chr.getProperties(); + byte[] value; + if ((properties & BluetoothGattCharacteristic.PROPERTY_NOTIFY) == BluetoothGattCharacteristic.PROPERTY_NOTIFY) { + value = BluetoothGattDescriptor.ENABLE_NOTIFICATION_VALUE; + } else if ((properties & BluetoothGattCharacteristic.PROPERTY_INDICATE) == BluetoothGattCharacteristic.PROPERTY_INDICATE) { + value = BluetoothGattDescriptor.ENABLE_INDICATION_VALUE; + } else { + Log.e(TAG, "Unable to start notifications on input characteristic"); + mResult = false; + return; + } + + mGatt.setCharacteristicNotification(chr, true); + cccd.setValue(value); + if (!mGatt.writeDescriptor(cccd)) { + Log.e(TAG, "Unable to write descriptor " + mUuid.toString()); + mResult = false; + return; + } + mResult = true; + } + } + } + } + + public boolean finish() { + return mResult; + } + + private BluetoothGattCharacteristic getCharacteristic(UUID uuid) { + BluetoothGattService valveService = mGatt.getService(steamControllerService); + if (valveService == null) + return null; + return valveService.getCharacteristic(uuid); + } + + static public GattOperation readCharacteristic(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.CHR_READ, uuid); + } + + static public GattOperation writeCharacteristic(BluetoothGatt gatt, UUID uuid, byte[] value) { + return new GattOperation(gatt, Operation.CHR_WRITE, uuid, value); + } + + static public GattOperation enableNotification(BluetoothGatt gatt, UUID uuid) { + return new GattOperation(gatt, Operation.ENABLE_NOTIFICATION, uuid); + } + } + + public HIDDeviceBLESteamController(HIDDeviceManager manager, BluetoothDevice device) { + mManager = manager; + mDevice = device; + mDeviceId = mManager.getDeviceIDForIdentifier(getIdentifier()); + mIsRegistered = false; + mIsChromebook = mManager.getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + mOperations = new LinkedList(); + mHandler = new Handler(Looper.getMainLooper()); + + mGatt = connectGatt(); + // final HIDDeviceBLESteamController finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.checkConnectionForChromebookIssue(); + // } + // }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + public String getIdentifier() { + return String.format("SteamController.%s", mDevice.getAddress()); + } + + public BluetoothGatt getGatt() { + return mGatt; + } + + // Because on Chromebooks we show up as a dual-mode device, it will attempt to connect TRANSPORT_AUTO, which will use TRANSPORT_BREDR instead + // of TRANSPORT_LE. Let's force ourselves to connect low energy. + private BluetoothGatt connectGatt(boolean managed) { + if (Build.VERSION.SDK_INT >= 23 /* Android 6.0 (M) */) { + try { + return mDevice.connectGatt(mManager.getContext(), managed, this, TRANSPORT_LE); + } catch (Exception e) { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } else { + return mDevice.connectGatt(mManager.getContext(), managed, this); + } + } + + private BluetoothGatt connectGatt() { + return connectGatt(false); + } + + protected int getConnectionState() { + + Context context = mManager.getContext(); + if (context == null) { + // We are lacking any context to get our Bluetooth information. We'll just assume disconnected. + return BluetoothProfile.STATE_DISCONNECTED; + } + + BluetoothManager btManager = (BluetoothManager)context.getSystemService(Context.BLUETOOTH_SERVICE); + if (btManager == null) { + // This device doesn't support Bluetooth. We should never be here, because how did + // we instantiate a device to start with? + return BluetoothProfile.STATE_DISCONNECTED; + } + + return btManager.getConnectionState(mDevice, BluetoothProfile.GATT); + } + + public void reconnect() { + + if (getConnectionState() != BluetoothProfile.STATE_CONNECTED) { + mGatt.disconnect(); + mGatt = connectGatt(); + } + + } + + protected void checkConnectionForChromebookIssue() { + if (!mIsChromebook) { + // We only do this on Chromebooks, because otherwise it's really annoying to just attempt + // over and over. + return; + } + + int connectionState = getConnectionState(); + + switch (connectionState) { + case BluetoothProfile.STATE_CONNECTED: + if (!mIsConnected) { + // We are in the Bad Chromebook Place. We can force a disconnect + // to try to recover. + Log.v(TAG, "Chromebook: We are in a very bad state; the controller shows as connected in the underlying Bluetooth layer, but we never received a callback. Forcing a reconnect."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + else if (!isRegistered()) { + if (mGatt.getServices().size() > 0) { + Log.v(TAG, "Chromebook: We are connected to a controller, but never got our registration. Trying to recover."); + probeService(this); + } + else { + Log.v(TAG, "Chromebook: We are connected to a controller, but never discovered services. Trying to recover."); + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + } + } + else { + Log.v(TAG, "Chromebook: We are connected, and registered. Everything's good!"); + return; + } + break; + + case BluetoothProfile.STATE_DISCONNECTED: + Log.v(TAG, "Chromebook: We have either been disconnected, or the Chromebook BtGatt.ContextMap bug has bitten us. Attempting a disconnect/reconnect, but we may not be able to recover."); + + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + break; + + case BluetoothProfile.STATE_CONNECTING: + Log.v(TAG, "Chromebook: We're still trying to connect. Waiting a bit longer."); + break; + } + + final HIDDeviceBLESteamController finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.checkConnectionForChromebookIssue(); + } + }, CHROMEBOOK_CONNECTION_CHECK_INTERVAL); + } + + private boolean isRegistered() { + return mIsRegistered; + } + + private void setRegistered() { + mIsRegistered = true; + } + + private boolean probeService(HIDDeviceBLESteamController controller) { + + if (isRegistered()) { + return true; + } + + if (!mIsConnected) { + return false; + } + + Log.v(TAG, "probeService controller=" + controller); + + for (BluetoothGattService service : mGatt.getServices()) { + if (service.getUuid().equals(steamControllerService)) { + Log.v(TAG, "Found Valve steam controller service " + service.getUuid()); + + for (BluetoothGattCharacteristic chr : service.getCharacteristics()) { + if (chr.getUuid().equals(inputCharacteristic)) { + Log.v(TAG, "Found input characteristic"); + // Start notifications + BluetoothGattDescriptor cccd = chr.getDescriptor(UUID.fromString("00002902-0000-1000-8000-00805f9b34fb")); + if (cccd != null) { + enableNotification(chr.getUuid()); + } + } + } + return true; + } + } + + if ((mGatt.getServices().size() == 0) && mIsChromebook && !mIsReconnecting) { + Log.e(TAG, "Chromebook: Discovered services were empty; this almost certainly means the BtGatt.ContextMap bug has bitten us."); + mIsConnected = false; + mIsReconnecting = true; + mGatt.disconnect(); + mGatt = connectGatt(false); + } + + return false; + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private void finishCurrentGattOperation() { + GattOperation op = null; + synchronized (mOperations) { + if (mCurrentOperation != null) { + op = mCurrentOperation; + mCurrentOperation = null; + } + } + if (op != null) { + boolean result = op.finish(); // TODO: Maybe in main thread as well? + + // Our operation failed, let's add it back to the beginning of our queue. + if (!result) { + mOperations.addFirst(op); + } + } + executeNextGattOperation(); + } + + private void executeNextGattOperation() { + synchronized (mOperations) { + if (mCurrentOperation != null) + return; + + if (mOperations.isEmpty()) + return; + + mCurrentOperation = mOperations.removeFirst(); + } + + // Run in main thread + mHandler.post(new Runnable() { + @Override + public void run() { + synchronized (mOperations) { + if (mCurrentOperation == null) { + Log.e(TAG, "Current operation null in executor?"); + return; + } + + mCurrentOperation.run(); + // now wait for the GATT callback and when it comes, finish this operation + } + } + }); + } + + private void queueGattOperation(GattOperation op) { + synchronized (mOperations) { + mOperations.add(op); + } + executeNextGattOperation(); + } + + private void enableNotification(UUID chrUuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.enableNotification(mGatt, chrUuid); + queueGattOperation(op); + } + + public void writeCharacteristic(UUID uuid, byte[] value) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.writeCharacteristic(mGatt, uuid, value); + queueGattOperation(op); + } + + public void readCharacteristic(UUID uuid) { + GattOperation op = HIDDeviceBLESteamController.GattOperation.readCharacteristic(mGatt, uuid); + queueGattOperation(op); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////// BluetoothGattCallback overridden methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public void onConnectionStateChange(BluetoothGatt g, int status, int newState) { + //Log.v(TAG, "onConnectionStateChange status=" + status + " newState=" + newState); + mIsReconnecting = false; + if (newState == 2) { + mIsConnected = true; + // Run directly, without GattOperation + if (!isRegistered()) { + mHandler.post(new Runnable() { + @Override + public void run() { + mGatt.discoverServices(); + } + }); + } + } + else if (newState == 0) { + mIsConnected = false; + } + + // Disconnection is handled in SteamLink using the ACTION_ACL_DISCONNECTED Intent. + } + + public void onServicesDiscovered(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onServicesDiscovered status=" + status); + if (status == 0) { + if (gatt.getServices().size() == 0) { + Log.v(TAG, "onServicesDiscovered returned zero services; something has gone horribly wrong down in Android's Bluetooth stack."); + mIsReconnecting = true; + mIsConnected = false; + gatt.disconnect(); + mGatt = connectGatt(false); + } + else { + probeService(this); + } + } + } + + public void onCharacteristicRead(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicRead status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic) && !mFrozen) { + mManager.HIDDeviceFeatureReport(getId(), characteristic.getValue()); + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicWrite(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic, int status) { + //Log.v(TAG, "onCharacteristicWrite status=" + status + " uuid=" + characteristic.getUuid()); + + if (characteristic.getUuid().equals(reportCharacteristic)) { + // Only register controller with the native side once it has been fully configured + if (!isRegistered()) { + Log.v(TAG, "Registering Steam Controller with ID: " + getId()); + mManager.HIDDeviceConnected(getId(), getIdentifier(), getVendorId(), getProductId(), getSerialNumber(), getVersion(), getManufacturerName(), getProductName(), 0, 0, 0, 0); + setRegistered(); + } + } + + finishCurrentGattOperation(); + } + + public void onCharacteristicChanged(BluetoothGatt gatt, BluetoothGattCharacteristic characteristic) { + // Enable this for verbose logging of controller input reports + //Log.v(TAG, "onCharacteristicChanged uuid=" + characteristic.getUuid() + " data=" + HexDump.dumpHexString(characteristic.getValue())); + + if (characteristic.getUuid().equals(inputCharacteristic) && !mFrozen) { + mManager.HIDDeviceInputReport(getId(), characteristic.getValue()); + } + } + + public void onDescriptorRead(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + //Log.v(TAG, "onDescriptorRead status=" + status); + } + + public void onDescriptorWrite(BluetoothGatt gatt, BluetoothGattDescriptor descriptor, int status) { + BluetoothGattCharacteristic chr = descriptor.getCharacteristic(); + //Log.v(TAG, "onDescriptorWrite status=" + status + " uuid=" + chr.getUuid() + " descriptor=" + descriptor.getUuid()); + + if (chr.getUuid().equals(inputCharacteristic)) { + boolean hasWrittenInputDescriptor = true; + BluetoothGattCharacteristic reportChr = chr.getService().getCharacteristic(reportCharacteristic); + if (reportChr != null) { + Log.v(TAG, "Writing report characteristic to enter valve mode"); + reportChr.setValue(enterValveMode); + gatt.writeCharacteristic(reportChr); + } + } + + finishCurrentGattOperation(); + } + + public void onReliableWriteCompleted(BluetoothGatt gatt, int status) { + //Log.v(TAG, "onReliableWriteCompleted status=" + status); + } + + public void onReadRemoteRssi(BluetoothGatt gatt, int rssi, int status) { + //Log.v(TAG, "onReadRemoteRssi status=" + status); + } + + public void onMtuChanged(BluetoothGatt gatt, int mtu, int status) { + //Log.v(TAG, "onMtuChanged status=" + status); + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + //////// Public API + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + // Valve Corporation + final int VALVE_USB_VID = 0x28DE; + return VALVE_USB_VID; + } + + @Override + public int getProductId() { + // We don't have an easy way to query from the Bluetooth device, but we know what it is + final int D0G_BLE2_PID = 0x1106; + return D0G_BLE2_PID; + } + + @Override + public String getSerialNumber() { + // This will be read later via feature report by Steam + return "12345"; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + return "Valve Corporation"; + } + + @Override + public String getProductName() { + return "Steam Controller"; + } + + @Override + public UsbDevice getDevice() { + return null; + } + + @Override + public boolean open() { + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + // We need to skip the first byte, as that doesn't go over the air + byte[] actual_report = Arrays.copyOfRange(report, 1, report.length - 1); + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(actual_report)); + writeCharacteristic(reportCharacteristic, actual_report); + return report.length; + } + + @Override + public int sendOutputReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted sendOutputReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return -1; + } + + //Log.v(TAG, "sendFeatureReport " + HexDump.dumpHexString(report)); + writeCharacteristic(reportCharacteristic, report); + return report.length; + } + + @Override + public boolean getFeatureReport(byte[] report) { + if (!isRegistered()) { + Log.e(TAG, "Attempted getFeatureReport before Steam Controller is registered!"); + if (mIsConnected) { + probeService(this); + } + return false; + } + + //Log.v(TAG, "getFeatureReport"); + readCharacteristic(reportCharacteristic); + return true; + } + + @Override + public void close() { + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + @Override + public void shutdown() { + close(); + + BluetoothGatt g = mGatt; + if (g != null) { + g.disconnect(); + g.close(); + mGatt = null; + } + mManager = null; + mIsRegistered = false; + mIsConnected = false; + mOperations.clear(); + } + +} + diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java new file mode 100644 index 0000000..5310d60 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceManager.java @@ -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 mDevicesById = new HashMap(); + private HashMap mBluetoothDevices = new HashMap(); + private int mNextDeviceId = 0; + private SharedPreferences mSharedPreferences = null; + private boolean mIsChromebook = false; + private UsbManager mUsbManager; + private Handler mHandler; + private BluetoothManager mBluetoothManager; + private List mLastBluetoothDevices; + + private final BroadcastReceiver mUsbBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (action.equals(UsbManager.ACTION_USB_DEVICE_ATTACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceAttached(usbDevice); + } else if (action.equals(UsbManager.ACTION_USB_DEVICE_DETACHED)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDeviceDetached(usbDevice); + } else if (action.equals(HIDDeviceManager.ACTION_USB_PERMISSION)) { + UsbDevice usbDevice = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + handleUsbDevicePermission(usbDevice, intent.getBooleanExtra(UsbManager.EXTRA_PERMISSION_GRANTED, false)); + } + } + }; + + private final BroadcastReceiver mBluetoothBroadcast = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + // Bluetooth device was connected. If it was a Steam Controller, handle it + if (action.equals(BluetoothDevice.ACTION_ACL_CONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device connected: " + device); + + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + } + + // Bluetooth device was disconnected, remove from controller manager (if any) + if (action.equals(BluetoothDevice.ACTION_ACL_DISCONNECTED)) { + BluetoothDevice device = intent.getParcelableExtra(BluetoothDevice.EXTRA_DEVICE); + Log.d(TAG, "Bluetooth device disconnected: " + device); + + disconnectBluetoothDevice(device); + } + } + }; + + private HIDDeviceManager(final Context context) { + mContext = context; + + HIDDeviceRegisterCallback(); + + mSharedPreferences = mContext.getSharedPreferences("hidapi", Context.MODE_PRIVATE); + mIsChromebook = mContext.getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + +// if (shouldClear) { +// SharedPreferences.Editor spedit = mSharedPreferences.edit(); +// spedit.clear(); +// spedit.commit(); +// } +// else + { + mNextDeviceId = mSharedPreferences.getInt("next_device_id", 0); + } + } + + public Context getContext() { + return mContext; + } + + public int getDeviceIDForIdentifier(String identifier) { + SharedPreferences.Editor spedit = mSharedPreferences.edit(); + + int result = mSharedPreferences.getInt(identifier, 0); + if (result == 0) { + result = mNextDeviceId++; + spedit.putInt("next_device_id", mNextDeviceId); + } + + spedit.putInt(identifier, result); + spedit.commit(); + return result; + } + + private void initializeUSB() { + mUsbManager = (UsbManager)mContext.getSystemService(Context.USB_SERVICE); + if (mUsbManager == null) { + return; + } + + /* + // Logging + for (UsbDevice device : mUsbManager.getDeviceList().values()) { + Log.i(TAG,"Path: " + device.getDeviceName()); + Log.i(TAG,"Manufacturer: " + device.getManufacturerName()); + Log.i(TAG,"Product: " + device.getProductName()); + Log.i(TAG,"ID: " + device.getDeviceId()); + Log.i(TAG,"Class: " + device.getDeviceClass()); + Log.i(TAG,"Protocol: " + device.getDeviceProtocol()); + Log.i(TAG,"Vendor ID " + device.getVendorId()); + Log.i(TAG,"Product ID: " + device.getProductId()); + Log.i(TAG,"Interface count: " + device.getInterfaceCount()); + Log.i(TAG,"---------------------------------------"); + + // Get interface details + for (int index = 0; index < device.getInterfaceCount(); index++) { + UsbInterface mUsbInterface = device.getInterface(index); + Log.i(TAG," ***** *****"); + Log.i(TAG," Interface index: " + index); + Log.i(TAG," Interface ID: " + mUsbInterface.getId()); + Log.i(TAG," Interface class: " + mUsbInterface.getInterfaceClass()); + Log.i(TAG," Interface subclass: " + mUsbInterface.getInterfaceSubclass()); + Log.i(TAG," Interface protocol: " + mUsbInterface.getInterfaceProtocol()); + Log.i(TAG," Endpoint count: " + mUsbInterface.getEndpointCount()); + + // Get endpoint details + for (int epi = 0; epi < mUsbInterface.getEndpointCount(); epi++) + { + UsbEndpoint mEndpoint = mUsbInterface.getEndpoint(epi); + Log.i(TAG," ++++ ++++ ++++"); + Log.i(TAG," Endpoint index: " + epi); + Log.i(TAG," Attributes: " + mEndpoint.getAttributes()); + Log.i(TAG," Direction: " + mEndpoint.getDirection()); + Log.i(TAG," Number: " + mEndpoint.getEndpointNumber()); + Log.i(TAG," Interval: " + mEndpoint.getInterval()); + Log.i(TAG," Packet size: " + mEndpoint.getMaxPacketSize()); + Log.i(TAG," Type: " + mEndpoint.getType()); + } + } + } + Log.i(TAG," No more devices connected."); + */ + + // Register for USB broadcasts and permission completions + IntentFilter filter = new IntentFilter(); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + filter.addAction(HIDDeviceManager.ACTION_USB_PERMISSION); + mContext.registerReceiver(mUsbBroadcast, filter); + + for (UsbDevice usbDevice : mUsbManager.getDeviceList().values()) { + handleUsbDeviceAttached(usbDevice); + } + } + + UsbManager getUSBManager() { + return mUsbManager; + } + + private void shutdownUSB() { + try { + mContext.unregisterReceiver(mUsbBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + private boolean isHIDDeviceInterface(UsbDevice usbDevice, UsbInterface usbInterface) { + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_HID) { + return true; + } + if (isXbox360Controller(usbDevice, usbInterface) || isXboxOneController(usbDevice, usbInterface)) { + return true; + } + return false; + } + + private boolean isXbox360Controller(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB360_IFACE_SUBCLASS = 93; + final int XB360_IFACE_PROTOCOL = 1; // Wired + final int XB360W_IFACE_PROTOCOL = 129; // Wireless + final int[] SUPPORTED_VENDORS = { + 0x0079, // GPD Win 2 + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x046d, // Logitech + 0x056e, // Elecom + 0x06a3, // Saitek + 0x0738, // Mad Catz + 0x07ff, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x1038, // SteelSeries + 0x11c9, // Nacon + 0x12ab, // Unknown + 0x1430, // RedOctane + 0x146b, // BigBen + 0x1532, // Razer Sabertooth + 0x15e4, // Numark + 0x162e, // Joytech + 0x1689, // Razer Onza + 0x1949, // Lab126, Inc. + 0x1bad, // Harmonix + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2c22, // Qanba + 0x2dc8, // 8BitDo + 0x9886, // ASTRO Gaming + }; + + if (usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB360_IFACE_SUBCLASS && + (usbInterface.getInterfaceProtocol() == XB360_IFACE_PROTOCOL || + usbInterface.getInterfaceProtocol() == XB360W_IFACE_PROTOCOL)) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private boolean isXboxOneController(UsbDevice usbDevice, UsbInterface usbInterface) { + final int XB1_IFACE_SUBCLASS = 71; + final int XB1_IFACE_PROTOCOL = 208; + final int[] SUPPORTED_VENDORS = { + 0x03f0, // HP + 0x044f, // Thrustmaster + 0x045e, // Microsoft + 0x0738, // Mad Catz + 0x0e6f, // PDP + 0x0f0d, // Hori + 0x10f5, // Turtle Beach + 0x1532, // Razer Wildcat + 0x20d6, // PowerA + 0x24c6, // PowerA + 0x2dc8, // 8BitDo + 0x2e24, // Hyperkin + }; + + if (usbInterface.getId() == 0 && + usbInterface.getInterfaceClass() == UsbConstants.USB_CLASS_VENDOR_SPEC && + usbInterface.getInterfaceSubclass() == XB1_IFACE_SUBCLASS && + usbInterface.getInterfaceProtocol() == XB1_IFACE_PROTOCOL) { + int vendor_id = usbDevice.getVendorId(); + for (int supportedVid : SUPPORTED_VENDORS) { + if (vendor_id == supportedVid) { + return true; + } + } + } + return false; + } + + private void handleUsbDeviceAttached(UsbDevice usbDevice) { + connectHIDDeviceUSB(usbDevice); + } + + private void handleUsbDeviceDetached(UsbDevice usbDevice) { + List devices = new ArrayList(); + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + devices.add(device.getId()); + } + } + for (int id : devices) { + HIDDevice device = mDevicesById.get(id); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + private void handleUsbDevicePermission(UsbDevice usbDevice, boolean permission_granted) { + for (HIDDevice device : mDevicesById.values()) { + if (usbDevice.equals(device.getDevice())) { + boolean opened = false; + if (permission_granted) { + opened = device.open(); + } + HIDDeviceOpenResult(device.getId(), opened); + } + } + } + + private void connectHIDDeviceUSB(UsbDevice usbDevice) { + synchronized (this) { + int interface_mask = 0; + for (int interface_index = 0; interface_index < usbDevice.getInterfaceCount(); interface_index++) { + UsbInterface usbInterface = usbDevice.getInterface(interface_index); + if (isHIDDeviceInterface(usbDevice, usbInterface)) { + // Check to see if we've already added this interface + // This happens with the Xbox Series X controller which has a duplicate interface 0, which is inactive + int interface_id = usbInterface.getId(); + if ((interface_mask & (1 << interface_id)) != 0) { + continue; + } + interface_mask |= (1 << interface_id); + + HIDDeviceUSB device = new HIDDeviceUSB(this, usbDevice, interface_index); + int id = device.getId(); + mDevicesById.put(id, device); + HIDDeviceConnected(id, device.getIdentifier(), device.getVendorId(), device.getProductId(), device.getSerialNumber(), device.getVersion(), device.getManufacturerName(), device.getProductName(), usbInterface.getId(), usbInterface.getInterfaceClass(), usbInterface.getInterfaceSubclass(), usbInterface.getInterfaceProtocol()); + } + } + } + } + + private void initializeBluetooth() { + Log.d(TAG, "Initializing Bluetooth"); + + if (Build.VERSION.SDK_INT <= 30 /* Android 11.0 (R) */ && + mContext.getPackageManager().checkPermission(android.Manifest.permission.BLUETOOTH, mContext.getPackageName()) != PackageManager.PERMISSION_GRANTED) { + Log.d(TAG, "Couldn't initialize Bluetooth, missing android.permission.BLUETOOTH"); + return; + } + + if (!mContext.getPackageManager().hasSystemFeature(PackageManager.FEATURE_BLUETOOTH_LE) || (Build.VERSION.SDK_INT < 18 /* Android 4.3 (JELLY_BEAN_MR2) */)) { + Log.d(TAG, "Couldn't initialize Bluetooth, this version of Android does not support Bluetooth LE"); + return; + } + + // Find bonded bluetooth controllers and create SteamControllers for them + mBluetoothManager = (BluetoothManager)mContext.getSystemService(Context.BLUETOOTH_SERVICE); + if (mBluetoothManager == null) { + // This device doesn't support Bluetooth. + return; + } + + BluetoothAdapter btAdapter = mBluetoothManager.getAdapter(); + if (btAdapter == null) { + // This device has Bluetooth support in the codebase, but has no available adapters. + return; + } + + // Get our bonded devices. + for (BluetoothDevice device : btAdapter.getBondedDevices()) { + + Log.d(TAG, "Bluetooth device available: " + device); + if (isSteamController(device)) { + connectBluetoothDevice(device); + } + + } + + // NOTE: These don't work on Chromebooks, to my undying dismay. + IntentFilter filter = new IntentFilter(); + filter.addAction(BluetoothDevice.ACTION_ACL_CONNECTED); + filter.addAction(BluetoothDevice.ACTION_ACL_DISCONNECTED); + mContext.registerReceiver(mBluetoothBroadcast, filter); + + if (mIsChromebook) { + mHandler = new Handler(Looper.getMainLooper()); + mLastBluetoothDevices = new ArrayList(); + + // final HIDDeviceManager finalThis = this; + // mHandler.postDelayed(new Runnable() { + // @Override + // public void run() { + // finalThis.chromebookConnectionHandler(); + // } + // }, 5000); + } + } + + private void shutdownBluetooth() { + try { + mContext.unregisterReceiver(mBluetoothBroadcast); + } catch (Exception e) { + // We may not have registered, that's okay + } + } + + // Chromebooks do not pass along ACTION_ACL_CONNECTED / ACTION_ACL_DISCONNECTED properly. + // This function provides a sort of dummy version of that, watching for changes in the + // connected devices and attempting to add controllers as things change. + public void chromebookConnectionHandler() { + if (!mIsChromebook) { + return; + } + + ArrayList disconnected = new ArrayList(); + ArrayList connected = new ArrayList(); + + List currentConnected = mBluetoothManager.getConnectedDevices(BluetoothProfile.GATT); + + for (BluetoothDevice bluetoothDevice : currentConnected) { + if (!mLastBluetoothDevices.contains(bluetoothDevice)) { + connected.add(bluetoothDevice); + } + } + for (BluetoothDevice bluetoothDevice : mLastBluetoothDevices) { + if (!currentConnected.contains(bluetoothDevice)) { + disconnected.add(bluetoothDevice); + } + } + + mLastBluetoothDevices = currentConnected; + + for (BluetoothDevice bluetoothDevice : disconnected) { + disconnectBluetoothDevice(bluetoothDevice); + } + for (BluetoothDevice bluetoothDevice : connected) { + connectBluetoothDevice(bluetoothDevice); + } + + final HIDDeviceManager finalThis = this; + mHandler.postDelayed(new Runnable() { + @Override + public void run() { + finalThis.chromebookConnectionHandler(); + } + }, 10000); + } + + public boolean connectBluetoothDevice(BluetoothDevice bluetoothDevice) { + Log.v(TAG, "connectBluetoothDevice device=" + bluetoothDevice); + synchronized (this) { + if (mBluetoothDevices.containsKey(bluetoothDevice)) { + Log.v(TAG, "Steam controller with address " + bluetoothDevice + " already exists, attempting reconnect"); + + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + device.reconnect(); + + return false; + } + HIDDeviceBLESteamController device = new HIDDeviceBLESteamController(this, bluetoothDevice); + int id = device.getId(); + mBluetoothDevices.put(bluetoothDevice, device); + mDevicesById.put(id, device); + + // The Steam Controller will mark itself connected once initialization is complete + } + return true; + } + + public void disconnectBluetoothDevice(BluetoothDevice bluetoothDevice) { + synchronized (this) { + HIDDeviceBLESteamController device = mBluetoothDevices.get(bluetoothDevice); + if (device == null) + return; + + int id = device.getId(); + mBluetoothDevices.remove(bluetoothDevice); + mDevicesById.remove(id); + device.shutdown(); + HIDDeviceDisconnected(id); + } + } + + public boolean isSteamController(BluetoothDevice bluetoothDevice) { + // Sanity check. If you pass in a null device, by definition it is never a Steam Controller. + if (bluetoothDevice == null) { + return false; + } + + // If the device has no local name, we really don't want to try an equality check against it. + if (bluetoothDevice.getName() == null) { + return false; + } + + return bluetoothDevice.getName().equals("SteamController") && ((bluetoothDevice.getType() & BluetoothDevice.DEVICE_TYPE_LE) != 0); + } + + private void close() { + shutdownUSB(); + shutdownBluetooth(); + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.shutdown(); + } + mDevicesById.clear(); + mBluetoothDevices.clear(); + HIDDeviceReleaseCallback(); + } + } + + public void setFrozen(boolean frozen) { + synchronized (this) { + for (HIDDevice device : mDevicesById.values()) { + device.setFrozen(frozen); + } + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private HIDDevice getDevice(int id) { + synchronized (this) { + HIDDevice result = mDevicesById.get(id); + if (result == null) { + Log.v(TAG, "No device for id: " + id); + Log.v(TAG, "Available devices: " + mDevicesById.keySet()); + } + return result; + } + } + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + ////////// JNI interface functions + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + public boolean initialize(boolean usb, boolean bluetooth) { + Log.v(TAG, "initialize(" + usb + ", " + bluetooth + ")"); + + if (usb) { + initializeUSB(); + } + if (bluetooth) { + initializeBluetooth(); + } + return true; + } + + public boolean openDevice(int deviceID) { + Log.v(TAG, "openDevice deviceID=" + deviceID); + HIDDevice device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + // Look to see if this is a USB device and we have permission to access it + UsbDevice usbDevice = device.getDevice(); + if (usbDevice != null && !mUsbManager.hasPermission(usbDevice)) { + HIDDeviceOpenPending(deviceID); + try { + final int FLAG_MUTABLE = 0x02000000; // PendingIntent.FLAG_MUTABLE, but don't require SDK 31 + int flags; + if (Build.VERSION.SDK_INT >= 31 /* Android 12.0 (S) */) { + flags = FLAG_MUTABLE; + } else { + flags = 0; + } + mUsbManager.requestPermission(usbDevice, PendingIntent.getBroadcast(mContext, 0, new Intent(HIDDeviceManager.ACTION_USB_PERMISSION), flags)); + } catch (Exception e) { + Log.v(TAG, "Couldn't request permission for USB device " + usbDevice); + HIDDeviceOpenResult(deviceID, false); + } + return false; + } + + try { + return device.open(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public int sendOutputReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendOutputReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendOutputReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public int sendFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "sendFeatureReport deviceID=" + deviceID + " length=" + report.length); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return -1; + } + + return device.sendFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return -1; + } + + public boolean getFeatureReport(int deviceID, byte[] report) { + try { + //Log.v(TAG, "getFeatureReport deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return false; + } + + return device.getFeatureReport(report); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + return false; + } + + public void closeDevice(int deviceID) { + try { + Log.v(TAG, "closeDevice deviceID=" + deviceID); + HIDDevice device; + device = getDevice(deviceID); + if (device == null) { + HIDDeviceDisconnected(deviceID); + return; + } + + device.close(); + } catch (Exception e) { + Log.e(TAG, "Got exception: " + Log.getStackTraceString(e)); + } + } + + + ////////////////////////////////////////////////////////////////////////////////////////////////////// + /////////////// Native methods + ////////////////////////////////////////////////////////////////////////////////////////////////////// + + private native void HIDDeviceRegisterCallback(); + private native void HIDDeviceReleaseCallback(); + + native void HIDDeviceConnected(int deviceID, String identifier, int vendorId, int productId, String serial_number, int release_number, String manufacturer_string, String product_string, int interface_number, int interface_class, int interface_subclass, int interface_protocol); + native void HIDDeviceOpenPending(int deviceID); + native void HIDDeviceOpenResult(int deviceID, boolean opened); + native void HIDDeviceDisconnected(int deviceID); + + native void HIDDeviceInputReport(int deviceID, byte[] report); + native void HIDDeviceFeatureReport(int deviceID, byte[] report); +} diff --git a/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java b/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java new file mode 100644 index 0000000..bfe0cf9 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/HIDDeviceUSB.java @@ -0,0 +1,309 @@ +package org.libsdl.app; + +import android.hardware.usb.*; +import android.os.Build; +import android.util.Log; +import java.util.Arrays; + +class HIDDeviceUSB implements HIDDevice { + + private static final String TAG = "hidapi"; + + protected HIDDeviceManager mManager; + protected UsbDevice mDevice; + protected int mInterfaceIndex; + protected int mInterface; + protected int mDeviceId; + protected UsbDeviceConnection mConnection; + protected UsbEndpoint mInputEndpoint; + protected UsbEndpoint mOutputEndpoint; + protected InputThread mInputThread; + protected boolean mRunning; + protected boolean mFrozen; + + public HIDDeviceUSB(HIDDeviceManager manager, UsbDevice usbDevice, int interface_index) { + mManager = manager; + mDevice = usbDevice; + mInterfaceIndex = interface_index; + mInterface = mDevice.getInterface(mInterfaceIndex).getId(); + mDeviceId = manager.getDeviceIDForIdentifier(getIdentifier()); + mRunning = false; + } + + public String getIdentifier() { + return String.format("%s/%x/%x/%d", mDevice.getDeviceName(), mDevice.getVendorId(), mDevice.getProductId(), mInterfaceIndex); + } + + @Override + public int getId() { + return mDeviceId; + } + + @Override + public int getVendorId() { + return mDevice.getVendorId(); + } + + @Override + public int getProductId() { + return mDevice.getProductId(); + } + + @Override + public String getSerialNumber() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + try { + result = mDevice.getSerialNumber(); + } + catch (SecurityException exception) { + //Log.w(TAG, "App permissions mean we cannot get serial number for device " + getDeviceName() + " message: " + exception.getMessage()); + } + } + if (result == null) { + result = ""; + } + return result; + } + + @Override + public int getVersion() { + return 0; + } + + @Override + public String getManufacturerName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getManufacturerName(); + } + if (result == null) { + result = String.format("%x", getVendorId()); + } + return result; + } + + @Override + public String getProductName() { + String result = null; + if (Build.VERSION.SDK_INT >= 21 /* Android 5.0 (LOLLIPOP) */) { + result = mDevice.getProductName(); + } + if (result == null) { + result = String.format("%x", getProductId()); + } + return result; + } + + @Override + public UsbDevice getDevice() { + return mDevice; + } + + public String getDeviceName() { + return getManufacturerName() + " " + getProductName() + "(0x" + String.format("%x", getVendorId()) + "/0x" + String.format("%x", getProductId()) + ")"; + } + + @Override + public boolean open() { + mConnection = mManager.getUSBManager().openDevice(mDevice); + if (mConnection == null) { + Log.w(TAG, "Unable to open USB device " + getDeviceName()); + return false; + } + + // Force claim our interface + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + if (!mConnection.claimInterface(iface, true)) { + Log.w(TAG, "Failed to claim interfaces on USB device " + getDeviceName()); + close(); + return false; + } + + // Find the endpoints + for (int j = 0; j < iface.getEndpointCount(); j++) { + UsbEndpoint endpt = iface.getEndpoint(j); + switch (endpt.getDirection()) { + case UsbConstants.USB_DIR_IN: + if (mInputEndpoint == null) { + mInputEndpoint = endpt; + } + break; + case UsbConstants.USB_DIR_OUT: + if (mOutputEndpoint == null) { + mOutputEndpoint = endpt; + } + break; + } + } + + // Make sure the required endpoints were present + if (mInputEndpoint == null || mOutputEndpoint == null) { + Log.w(TAG, "Missing required endpoint on USB device " + getDeviceName()); + close(); + return false; + } + + // Start listening for input + mRunning = true; + mInputThread = new InputThread(); + mInputThread.start(); + + return true; + } + + @Override + public int sendFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_OUT, + 0x09/*HID set_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "sendFeatureReport() returned " + res + " on device " + getDeviceName()); + return -1; + } + + if (skipped_report_id) { + ++length; + } + return length; + } + + @Override + public int sendOutputReport(byte[] report) { + int r = mConnection.bulkTransfer(mOutputEndpoint, report, report.length, 1000); + if (r != report.length) { + Log.w(TAG, "sendOutputReport() returned " + r + " on device " + getDeviceName()); + } + return r; + } + + @Override + public boolean getFeatureReport(byte[] report) { + int res = -1; + int offset = 0; + int length = report.length; + boolean skipped_report_id = false; + byte report_number = report[0]; + + if (report_number == 0x0) { + /* Offset the return buffer by 1, so that the report ID + will remain in byte 0. */ + ++offset; + --length; + skipped_report_id = true; + } + + res = mConnection.controlTransfer( + UsbConstants.USB_TYPE_CLASS | 0x01 /*RECIPIENT_INTERFACE*/ | UsbConstants.USB_DIR_IN, + 0x01/*HID get_report*/, + (3/*HID feature*/ << 8) | report_number, + mInterface, + report, offset, length, + 1000/*timeout millis*/); + + if (res < 0) { + Log.w(TAG, "getFeatureReport() returned " + res + " on device " + getDeviceName()); + return false; + } + + if (skipped_report_id) { + ++res; + ++length; + } + + byte[] data; + if (res == length) { + data = report; + } else { + data = Arrays.copyOfRange(report, 0, res); + } + mManager.HIDDeviceFeatureReport(mDeviceId, data); + + return true; + } + + @Override + public void close() { + mRunning = false; + if (mInputThread != null) { + while (mInputThread.isAlive()) { + mInputThread.interrupt(); + try { + mInputThread.join(); + } catch (InterruptedException e) { + // Keep trying until we're done + } + } + mInputThread = null; + } + if (mConnection != null) { + UsbInterface iface = mDevice.getInterface(mInterfaceIndex); + mConnection.releaseInterface(iface); + mConnection.close(); + mConnection = null; + } + } + + @Override + public void shutdown() { + close(); + mManager = null; + } + + @Override + public void setFrozen(boolean frozen) { + mFrozen = frozen; + } + + protected class InputThread extends Thread { + @Override + public void run() { + int packetSize = mInputEndpoint.getMaxPacketSize(); + byte[] packet = new byte[packetSize]; + while (mRunning) { + int r; + try + { + r = mConnection.bulkTransfer(mInputEndpoint, packet, packetSize, 1000); + } + catch (Exception e) + { + Log.v(TAG, "Exception in UsbDeviceConnection bulktransfer: " + e); + break; + } + if (r < 0) { + // Could be a timeout or an I/O error + } + if (r > 0) { + byte[] data; + if (r == packetSize) { + data = packet; + } else { + data = Arrays.copyOfRange(packet, 0, r); + } + + if (!mFrozen) { + mManager.HIDDeviceInputReport(mDeviceId, data); + } + } + } + } + } +} diff --git a/android/app/src/main/java/org/libsdl/app/SDL.java b/android/app/src/main/java/org/libsdl/app/SDL.java new file mode 100644 index 0000000..44c21c1 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/SDL.java @@ -0,0 +1,86 @@ +package org.libsdl.app; + +import android.content.Context; + +import java.lang.Class; +import java.lang.reflect.Method; + +/** + SDL library initialization +*/ +public class SDL { + + // This function should be called first and sets up the native code + // so it can call into the Java classes + public static void setupJNI() { + SDLActivity.nativeSetupJNI(); + SDLAudioManager.nativeSetupJNI(); + SDLControllerManager.nativeSetupJNI(); + } + + // This function should be called each time the activity is started + public static void initialize() { + setContext(null); + + SDLActivity.initialize(); + SDLAudioManager.initialize(); + SDLControllerManager.initialize(); + } + + // This function stores the current activity (SDL or not) + public static void setContext(Context context) { + SDLAudioManager.setContext(context); + mContext = context; + } + + public static Context getContext() { + return mContext; + } + + public static void loadLibrary(String libraryName) throws UnsatisfiedLinkError, SecurityException, NullPointerException { + + if (libraryName == null) { + throw new NullPointerException("No library name provided."); + } + + try { + // Let's see if we have ReLinker available in the project. This is necessary for + // some projects that have huge numbers of local libraries bundled, and thus may + // trip a bug in Android's native library loader which ReLinker works around. (If + // loadLibrary works properly, ReLinker will simply use the normal Android method + // internally.) + // + // To use ReLinker, just add it as a dependency. For more information, see + // https://github.com/KeepSafe/ReLinker for ReLinker's repository. + // + Class relinkClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker"); + Class relinkListenerClass = mContext.getClassLoader().loadClass("com.getkeepsafe.relinker.ReLinker$LoadListener"); + Class contextClass = mContext.getClassLoader().loadClass("android.content.Context"); + Class stringClass = mContext.getClassLoader().loadClass("java.lang.String"); + + // Get a 'force' instance of the ReLinker, so we can ensure libraries are reinstalled if + // they've changed during updates. + Method forceMethod = relinkClass.getDeclaredMethod("force"); + Object relinkInstance = forceMethod.invoke(null); + Class relinkInstanceClass = relinkInstance.getClass(); + + // Actually load the library! + Method loadMethod = relinkInstanceClass.getDeclaredMethod("loadLibrary", contextClass, stringClass, stringClass, relinkListenerClass); + loadMethod.invoke(relinkInstance, mContext, libraryName, null, null); + } + catch (final Throwable e) { + // Fall back + try { + System.loadLibrary(libraryName); + } + catch (final UnsatisfiedLinkError ule) { + throw ule; + } + catch (final SecurityException se) { + throw se; + } + } + } + + protected static Context mContext; +} diff --git a/android/app/src/main/java/org/libsdl/app/SDLActivity.java b/android/app/src/main/java/org/libsdl/app/SDLActivity.java new file mode 100644 index 0000000..a2b5ba8 --- /dev/null +++ b/android/app/src/main/java/org/libsdl/app/SDLActivity.java @@ -0,0 +1,2117 @@ +package org.libsdl.app; + +import android.app.Activity; +import android.app.AlertDialog; +import android.app.Dialog; +import android.app.UiModeManager; +import android.content.ClipboardManager; +import android.content.ClipData; +import android.content.Context; +import android.content.DialogInterface; +import android.content.Intent; +import android.content.pm.ActivityInfo; +import android.content.pm.ApplicationInfo; +import android.content.pm.PackageManager; +import android.content.res.Configuration; +import android.graphics.Bitmap; +import android.graphics.Color; +import android.graphics.PorterDuff; +import android.graphics.drawable.Drawable; +import android.hardware.Sensor; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Message; +import android.text.Editable; +import android.text.InputType; +import android.text.Selection; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.SparseArray; +import android.view.Display; +import android.view.Gravity; +import android.view.InputDevice; +import android.view.KeyEvent; +import android.view.PointerIcon; +import android.view.Surface; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; +import android.view.WindowManager; +import android.view.inputmethod.BaseInputConnection; +import android.view.inputmethod.EditorInfo; +import android.view.inputmethod.InputConnection; +import android.view.inputmethod.InputMethodManager; +import android.widget.Button; +import android.widget.EditText; +import android.widget.LinearLayout; +import android.widget.RelativeLayout; +import android.widget.TextView; +import android.widget.Toast; + +import java.util.Hashtable; +import java.util.Locale; + + +/** + SDL Activity +*/ +public class SDLActivity extends Activity implements View.OnSystemUiVisibilityChangeListener { + private static final String TAG = "SDL"; + private static final int SDL_MAJOR_VERSION = 2; + private static final int SDL_MINOR_VERSION = 28; + private static final int SDL_MICRO_VERSION = 5; +/* + // Display InputType.SOURCE/CLASS of events and devices + // + // SDLActivity.debugSource(device.getSources(), "device[" + device.getName() + "]"); + // SDLActivity.debugSource(event.getSource(), "event"); + public static void debugSource(int sources, String prefix) { + int s = sources; + int s_copy = sources; + String cls = ""; + String src = ""; + int tst = 0; + int FLAG_TAINTED = 0x80000000; + + if ((s & InputDevice.SOURCE_CLASS_BUTTON) != 0) cls += " BUTTON"; + if ((s & InputDevice.SOURCE_CLASS_JOYSTICK) != 0) cls += " JOYSTICK"; + if ((s & InputDevice.SOURCE_CLASS_POINTER) != 0) cls += " POINTER"; + if ((s & InputDevice.SOURCE_CLASS_POSITION) != 0) cls += " POSITION"; + if ((s & InputDevice.SOURCE_CLASS_TRACKBALL) != 0) cls += " TRACKBALL"; + + + int s2 = s_copy & ~InputDevice.SOURCE_ANY; // keep class bits + s2 &= ~( InputDevice.SOURCE_CLASS_BUTTON + | InputDevice.SOURCE_CLASS_JOYSTICK + | InputDevice.SOURCE_CLASS_POINTER + | InputDevice.SOURCE_CLASS_POSITION + | InputDevice.SOURCE_CLASS_TRACKBALL); + + if (s2 != 0) cls += "Some_Unkown"; + + s2 = s_copy & InputDevice.SOURCE_ANY; // keep source only, no class; + + if (Build.VERSION.SDK_INT >= 23) { + tst = InputDevice.SOURCE_BLUETOOTH_STYLUS; + if ((s & tst) == tst) src += " BLUETOOTH_STYLUS"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_DPAD; + if ((s & tst) == tst) src += " DPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_GAMEPAD; + if ((s & tst) == tst) src += " GAMEPAD"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 21) { + tst = InputDevice.SOURCE_HDMI; + if ((s & tst) == tst) src += " HDMI"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_JOYSTICK; + if ((s & tst) == tst) src += " JOYSTICK"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_KEYBOARD; + if ((s & tst) == tst) src += " KEYBOARD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_MOUSE; + if ((s & tst) == tst) src += " MOUSE"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 26) { + tst = InputDevice.SOURCE_MOUSE_RELATIVE; + if ((s & tst) == tst) src += " MOUSE_RELATIVE"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ROTARY_ENCODER; + if ((s & tst) == tst) src += " ROTARY_ENCODER"; + s2 &= ~tst; + } + tst = InputDevice.SOURCE_STYLUS; + if ((s & tst) == tst) src += " STYLUS"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHPAD; + if ((s & tst) == tst) src += " TOUCHPAD"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_TOUCHSCREEN; + if ((s & tst) == tst) src += " TOUCHSCREEN"; + s2 &= ~tst; + + if (Build.VERSION.SDK_INT >= 18) { + tst = InputDevice.SOURCE_TOUCH_NAVIGATION; + if ((s & tst) == tst) src += " TOUCH_NAVIGATION"; + s2 &= ~tst; + } + + tst = InputDevice.SOURCE_TRACKBALL; + if ((s & tst) == tst) src += " TRACKBALL"; + s2 &= ~tst; + + tst = InputDevice.SOURCE_ANY; + if ((s & tst) == tst) src += " ANY"; + s2 &= ~tst; + + if (s == FLAG_TAINTED) src += " FLAG_TAINTED"; + s2 &= ~FLAG_TAINTED; + + if (s2 != 0) src += " Some_Unkown"; + + Log.v(TAG, prefix + "int=" + s_copy + " CLASS={" + cls + " } source(s):" + src); + } +*/ + + public static boolean mIsResumedCalled, mHasFocus; + public static final boolean mHasMultiWindow = (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */); + + // Cursor types + // private static final int SDL_SYSTEM_CURSOR_NONE = -1; + private static final int SDL_SYSTEM_CURSOR_ARROW = 0; + private static final int SDL_SYSTEM_CURSOR_IBEAM = 1; + private static final int SDL_SYSTEM_CURSOR_WAIT = 2; + private static final int SDL_SYSTEM_CURSOR_CROSSHAIR = 3; + private static final int SDL_SYSTEM_CURSOR_WAITARROW = 4; + private static final int SDL_SYSTEM_CURSOR_SIZENWSE = 5; + private static final int SDL_SYSTEM_CURSOR_SIZENESW = 6; + private static final int SDL_SYSTEM_CURSOR_SIZEWE = 7; + private static final int SDL_SYSTEM_CURSOR_SIZENS = 8; + private static final int SDL_SYSTEM_CURSOR_SIZEALL = 9; + private static final int SDL_SYSTEM_CURSOR_NO = 10; + private static final int SDL_SYSTEM_CURSOR_HAND = 11; + + protected static final int SDL_ORIENTATION_UNKNOWN = 0; + protected static final int SDL_ORIENTATION_LANDSCAPE = 1; + protected static final int SDL_ORIENTATION_LANDSCAPE_FLIPPED = 2; + protected static final int SDL_ORIENTATION_PORTRAIT = 3; + protected static final int SDL_ORIENTATION_PORTRAIT_FLIPPED = 4; + + protected static int mCurrentOrientation; + protected static Locale mCurrentLocale; + + // Handle the state of the native layer + public enum NativeState { + INIT, RESUMED, PAUSED + } + + public static NativeState mNextNativeState; + public static NativeState mCurrentNativeState; + + /** If shared libraries (e.g. SDL or the native application) could not be loaded. */ + public static boolean mBrokenLibraries = true; + + // Main components + protected static SDLActivity mSingleton; + protected static SDLSurface mSurface; + protected static DummyEdit mTextEdit; + protected static boolean mScreenKeyboardShown; + protected static ViewGroup mLayout; + protected static SDLClipboardHandler mClipboardHandler; + protected static Hashtable mCursors; + protected static int mLastCursorID; + protected static SDLGenericMotionListener_API12 mMotionListener; + protected static HIDDeviceManager mHIDDeviceManager; + + // This is what SDL runs in. It invokes SDL_main(), eventually + protected static Thread mSDLThread; + + protected static SDLGenericMotionListener_API12 getMotionListener() { + if (mMotionListener == null) { + if (Build.VERSION.SDK_INT >= 26 /* Android 8.0 (O) */) { + mMotionListener = new SDLGenericMotionListener_API26(); + } else if (Build.VERSION.SDK_INT >= 24 /* Android 7.0 (N) */) { + mMotionListener = new SDLGenericMotionListener_API24(); + } else { + mMotionListener = new SDLGenericMotionListener_API12(); + } + } + + return mMotionListener; + } + + /** + * This method returns the name of the shared object with the application entry point + * It can be overridden by derived classes. + */ + protected String getMainSharedObject() { + String library; + String[] libraries = SDLActivity.mSingleton.getLibraries(); + if (libraries.length > 0) { + library = "lib" + libraries[libraries.length - 1] + ".so"; + } else { + library = "libmain.so"; + } + return getContext().getApplicationInfo().nativeLibraryDir + "/" + library; + } + + /** + * This method returns the name of the application entry point + * It can be overridden by derived classes. + */ + protected String getMainFunction() { + return "SDL_main"; + } + + /** + * This method is called by SDL before loading the native shared libraries. + * It can be overridden to provide names of shared libraries to be loaded. + * The default implementation returns the defaults. It never returns null. + * An array returned by a new implementation must at least contain "SDL2". + * Also keep in mind that the order the libraries are loaded may matter. + * @return names of shared libraries to be loaded (e.g. "SDL2", "main"). + */ + protected String[] getLibraries() { + return new String[] { + "SDL2", + // "SDL2_image", + // "SDL2_mixer", + // "SDL2_net", + // "SDL2_ttf", + "main" + }; + } + + // Load the .so + public void loadLibraries() { + for (String lib : getLibraries()) { + SDL.loadLibrary(lib); + } + } + + /** + * This method is called by SDL before starting the native application thread. + * It can be overridden to provide the arguments after the application name. + * The default implementation returns an empty array. It never returns null. + * @return arguments for the native application. + */ + protected String[] getArguments() { + return new String[0]; + } + + public static void initialize() { + // The static nature of the singleton and Android quirkyness force us to initialize everything here + // Otherwise, when exiting the app and returning to it, these variables *keep* their pre exit values + mSingleton = null; + mSurface = null; + mTextEdit = null; + mLayout = null; + mClipboardHandler = null; + mCursors = new Hashtable(); + mLastCursorID = 0; + mSDLThread = null; + mIsResumedCalled = false; + mHasFocus = true; + mNextNativeState = NativeState.INIT; + mCurrentNativeState = NativeState.INIT; + } + + protected SDLSurface createSDLSurface(Context context) { + return new SDLSurface(context); + } + + // Setup + @Override + protected void onCreate(Bundle savedInstanceState) { + Log.v(TAG, "Device: " + Build.DEVICE); + Log.v(TAG, "Model: " + Build.MODEL); + Log.v(TAG, "onCreate()"); + super.onCreate(savedInstanceState); + + try { + Thread.currentThread().setName("SDLActivity"); + } catch (Exception e) { + Log.v(TAG, "modify thread properties failed " + e.toString()); + } + + // Load shared libraries + String errorMsgBrokenLib = ""; + try { + loadLibraries(); + mBrokenLibraries = false; /* success */ + } catch(UnsatisfiedLinkError e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } catch(Exception e) { + System.err.println(e.getMessage()); + mBrokenLibraries = true; + errorMsgBrokenLib = e.getMessage(); + } + + if (!mBrokenLibraries) { + String expected_version = String.valueOf(SDL_MAJOR_VERSION) + "." + + String.valueOf(SDL_MINOR_VERSION) + "." + + String.valueOf(SDL_MICRO_VERSION); + String version = nativeGetVersion(); + if (!version.equals(expected_version)) { + mBrokenLibraries = true; + errorMsgBrokenLib = "SDL C/Java version mismatch (expected " + expected_version + ", got " + version + ")"; + } + } + + if (mBrokenLibraries) { + mSingleton = this; + AlertDialog.Builder dlgAlert = new AlertDialog.Builder(this); + dlgAlert.setMessage("An error occurred while trying to start the application. Please try again and/or reinstall." + + System.getProperty("line.separator") + + System.getProperty("line.separator") + + "Error: " + errorMsgBrokenLib); + dlgAlert.setTitle("SDL Error"); + dlgAlert.setPositiveButton("Exit", + new DialogInterface.OnClickListener() { + @Override + public void onClick(DialogInterface dialog,int id) { + // if this button is clicked, close current activity + SDLActivity.mSingleton.finish(); + } + }); + dlgAlert.setCancelable(false); + dlgAlert.create().show(); + + return; + } + + // Set up JNI + SDL.setupJNI(); + + // Initialize state + SDL.initialize(); + + // So we can call stuff from static callbacks + mSingleton = this; + SDL.setContext(this); + + mClipboardHandler = new SDLClipboardHandler(); + + mHIDDeviceManager = HIDDeviceManager.acquire(this); + + // Set up the surface + mSurface = createSDLSurface(this); + + mLayout = new RelativeLayout(this); + mLayout.addView(mSurface); + + // Get our current screen orientation and pass it down. + mCurrentOrientation = SDLActivity.getCurrentOrientation(); + // Only record current orientation + SDLActivity.onNativeOrientationChanged(mCurrentOrientation); + + try { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + mCurrentLocale = getContext().getResources().getConfiguration().locale; + } else { + mCurrentLocale = getContext().getResources().getConfiguration().getLocales().get(0); + } + } catch(Exception ignored) { + } + + setContentView(mLayout); + + setWindowStyle(false); + + getWindow().getDecorView().setOnSystemUiVisibilityChangeListener(this); + + // Get filename from "Open with" of another application + Intent intent = getIntent(); + if (intent != null && intent.getData() != null) { + String filename = intent.getData().getPath(); + if (filename != null) { + Log.v(TAG, "Got filename: " + filename); + SDLActivity.onNativeDropFile(filename); + } + } + } + + protected void pauseNativeThread() { + mNextNativeState = NativeState.PAUSED; + mIsResumedCalled = false; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + protected void resumeNativeThread() { + mNextNativeState = NativeState.RESUMED; + mIsResumedCalled = true; + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.handleNativeState(); + } + + // Events + @Override + protected void onPause() { + Log.v(TAG, "onPause()"); + super.onPause(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(true); + } + if (!mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onResume() { + Log.v(TAG, "onResume()"); + super.onResume(); + + if (mHIDDeviceManager != null) { + mHIDDeviceManager.setFrozen(false); + } + if (!mHasMultiWindow) { + resumeNativeThread(); + } + } + + @Override + protected void onStop() { + Log.v(TAG, "onStop()"); + super.onStop(); + if (mHasMultiWindow) { + pauseNativeThread(); + } + } + + @Override + protected void onStart() { + Log.v(TAG, "onStart()"); + super.onStart(); + if (mHasMultiWindow) { + resumeNativeThread(); + } + } + + public static int getCurrentOrientation() { + int result = SDL_ORIENTATION_UNKNOWN; + + Activity activity = (Activity)getContext(); + if (activity == null) { + return result; + } + Display display = activity.getWindowManager().getDefaultDisplay(); + + switch (display.getRotation()) { + case Surface.ROTATION_0: + result = SDL_ORIENTATION_PORTRAIT; + break; + + case Surface.ROTATION_90: + result = SDL_ORIENTATION_LANDSCAPE; + break; + + case Surface.ROTATION_180: + result = SDL_ORIENTATION_PORTRAIT_FLIPPED; + break; + + case Surface.ROTATION_270: + result = SDL_ORIENTATION_LANDSCAPE_FLIPPED; + break; + } + + return result; + } + + @Override + public void onWindowFocusChanged(boolean hasFocus) { + super.onWindowFocusChanged(hasFocus); + Log.v(TAG, "onWindowFocusChanged(): " + hasFocus); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + mHasFocus = hasFocus; + if (hasFocus) { + mNextNativeState = NativeState.RESUMED; + SDLActivity.getMotionListener().reclaimRelativeMouseModeIfNeeded(); + + SDLActivity.handleNativeState(); + nativeFocusChanged(true); + + } else { + nativeFocusChanged(false); + if (!mHasMultiWindow) { + mNextNativeState = NativeState.PAUSED; + SDLActivity.handleNativeState(); + } + } + } + + @Override + public void onLowMemory() { + Log.v(TAG, "onLowMemory()"); + super.onLowMemory(); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + SDLActivity.nativeLowMemory(); + } + + @Override + public void onConfigurationChanged(Configuration newConfig) { + Log.v(TAG, "onConfigurationChanged()"); + super.onConfigurationChanged(newConfig); + + if (SDLActivity.mBrokenLibraries) { + return; + } + + if (mCurrentLocale == null || !mCurrentLocale.equals(newConfig.locale)) { + mCurrentLocale = newConfig.locale; + SDLActivity.onNativeLocaleChanged(); + } + } + + @Override + protected void onDestroy() { + Log.v(TAG, "onDestroy()"); + + if (mHIDDeviceManager != null) { + HIDDeviceManager.release(mHIDDeviceManager); + mHIDDeviceManager = null; + } + + SDLAudioManager.release(this); + + if (SDLActivity.mBrokenLibraries) { + super.onDestroy(); + return; + } + + if (SDLActivity.mSDLThread != null) { + + // Send Quit event to "SDLThread" thread + SDLActivity.nativeSendQuit(); + + // Wait for "SDLThread" thread to end + try { + SDLActivity.mSDLThread.join(); + } catch(Exception e) { + Log.v(TAG, "Problem stopping SDLThread: " + e); + } + } + + SDLActivity.nativeQuit(); + + super.onDestroy(); + } + + @Override + public void onBackPressed() { + // Check if we want to block the back button in case of mouse right click. + // + // If we do, the normal hardware back button will no longer work and people have to use home, + // but the mouse right click will work. + // + boolean trapBack = SDLActivity.nativeGetHintBoolean("SDL_ANDROID_TRAP_BACK_BUTTON", false); + if (trapBack) { + // Exit and let the mouse handler handle this button (if appropriate) + return; + } + + // Default system back button behavior. + if (!isFinishing()) { + super.onBackPressed(); + } + } + + // Called by JNI from SDL. + public static void manualBackButton() { + mSingleton.pressBackButton(); + } + + // Used to get us onto the activity's main thread + public void pressBackButton() { + runOnUiThread(new Runnable() { + @Override + public void run() { + if (!SDLActivity.this.isFinishing()) { + SDLActivity.this.superOnBackPressed(); + } + } + }); + } + + // Used to access the system back behavior. + public void superOnBackPressed() { + super.onBackPressed(); + } + + @Override + public boolean dispatchKeyEvent(KeyEvent event) { + + if (SDLActivity.mBrokenLibraries) { + return false; + } + + int keyCode = event.getKeyCode(); + // Ignore certain special keys so they're handled by Android + if (keyCode == KeyEvent.KEYCODE_VOLUME_DOWN || + keyCode == KeyEvent.KEYCODE_VOLUME_UP || + keyCode == KeyEvent.KEYCODE_CAMERA || + keyCode == KeyEvent.KEYCODE_ZOOM_IN || /* API 11 */ + keyCode == KeyEvent.KEYCODE_ZOOM_OUT /* API 11 */ + ) { + return false; + } + return super.dispatchKeyEvent(event); + } + + /* Transition to next state */ + public static void handleNativeState() { + + if (mNextNativeState == mCurrentNativeState) { + // Already in same state, discard. + return; + } + + // Try a transition to init state + if (mNextNativeState == NativeState.INIT) { + + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to paused state + if (mNextNativeState == NativeState.PAUSED) { + if (mSDLThread != null) { + nativePause(); + } + if (mSurface != null) { + mSurface.handlePause(); + } + mCurrentNativeState = mNextNativeState; + return; + } + + // Try a transition to resumed state + if (mNextNativeState == NativeState.RESUMED) { + if (mSurface.mIsSurfaceReady && mHasFocus && mIsResumedCalled) { + if (mSDLThread == null) { + // This is the entry point to the C app. + // Start up the C app thread and enable sensor input for the first time + // FIXME: Why aren't we enabling sensor input at start? + + mSDLThread = new Thread(new SDLMain(), "SDLThread"); + mSurface.enableSensor(Sensor.TYPE_ACCELEROMETER, true); + mSDLThread.start(); + + // No nativeResume(), don't signal Android_ResumeSem + } else { + nativeResume(); + } + mSurface.handleResume(); + + mCurrentNativeState = mNextNativeState; + } + } + } + + // Messages from the SDLMain thread + static final int COMMAND_CHANGE_TITLE = 1; + static final int COMMAND_CHANGE_WINDOW_STYLE = 2; + static final int COMMAND_TEXTEDIT_HIDE = 3; + static final int COMMAND_SET_KEEP_SCREEN_ON = 5; + + protected static final int COMMAND_USER = 0x8000; + + protected static boolean mFullscreenModeActive; + + /** + * This method is called by SDL if SDL did not handle a message itself. + * This happens if a received message contains an unsupported command. + * Method can be overwritten to handle Messages in a different class. + * @param command the command of the message. + * @param param the parameter of the message. May be null. + * @return if the message was handled in overridden method. + */ + protected boolean onUnhandledMessage(int command, Object param) { + return false; + } + + /** + * A Handler class for Messages from native SDL applications. + * It uses current Activities as target (e.g. for the title). + * static to prevent implicit references to enclosing object. + */ + protected static class SDLCommandHandler extends Handler { + @Override + public void handleMessage(Message msg) { + Context context = SDL.getContext(); + if (context == null) { + Log.e(TAG, "error handling message, getContext() returned null"); + return; + } + switch (msg.arg1) { + case COMMAND_CHANGE_TITLE: + if (context instanceof Activity) { + ((Activity) context).setTitle((String)msg.obj); + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + break; + case COMMAND_CHANGE_WINDOW_STYLE: + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + int flags = View.SYSTEM_UI_FLAG_FULLSCREEN | + View.SYSTEM_UI_FLAG_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY | + View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN | + View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | + View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.INVISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + SDLActivity.mFullscreenModeActive = true; + } else { + int flags = View.SYSTEM_UI_FLAG_LAYOUT_STABLE | View.SYSTEM_UI_FLAG_VISIBLE; + window.getDecorView().setSystemUiVisibility(flags); + window.addFlags(WindowManager.LayoutParams.FLAG_FORCE_NOT_FULLSCREEN); + window.clearFlags(WindowManager.LayoutParams.FLAG_FULLSCREEN); + SDLActivity.mFullscreenModeActive = false; + } + } + } else { + Log.e(TAG, "error handling message, getContext() returned no Activity"); + } + } + break; + case COMMAND_TEXTEDIT_HIDE: + if (mTextEdit != null) { + // Note: On some devices setting view to GONE creates a flicker in landscape. + // Setting the View's sizes to 0 is similar to GONE but without the flicker. + // The sizes will be set to useful values when the keyboard is shown again. + mTextEdit.setLayoutParams(new RelativeLayout.LayoutParams(0, 0)); + + InputMethodManager imm = (InputMethodManager) context.getSystemService(Context.INPUT_METHOD_SERVICE); + imm.hideSoftInputFromWindow(mTextEdit.getWindowToken(), 0); + + mScreenKeyboardShown = false; + + mSurface.requestFocus(); + } + break; + case COMMAND_SET_KEEP_SCREEN_ON: + { + if (context instanceof Activity) { + Window window = ((Activity) context).getWindow(); + if (window != null) { + if ((msg.obj instanceof Integer) && ((Integer) msg.obj != 0)) { + window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } else { + window.clearFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); + } + } + } + break; + } + default: + if ((context instanceof SDLActivity) && !((SDLActivity) context).onUnhandledMessage(msg.arg1, msg.obj)) { + Log.e(TAG, "error handling message, command is " + msg.arg1); + } + } + } + } + + // Handler for the messages + Handler commandHandler = new SDLCommandHandler(); + + // Send a message from the SDLMain thread + boolean sendCommand(int command, Object data) { + Message msg = commandHandler.obtainMessage(); + msg.arg1 = command; + msg.obj = data; + boolean result = commandHandler.sendMessage(msg); + + if (Build.VERSION.SDK_INT >= 19 /* Android 4.4 (KITKAT) */) { + if (command == COMMAND_CHANGE_WINDOW_STYLE) { + // Ensure we don't return until the resize has actually happened, + // or 500ms have passed. + + boolean bShouldWait = false; + + if (data instanceof Integer) { + // Let's figure out if we're already laid out fullscreen or not. + Display display = ((WindowManager) getSystemService(Context.WINDOW_SERVICE)).getDefaultDisplay(); + DisplayMetrics realMetrics = new DisplayMetrics(); + display.getRealMetrics(realMetrics); + + boolean bFullscreenLayout = ((realMetrics.widthPixels == mSurface.getWidth()) && + (realMetrics.heightPixels == mSurface.getHeight())); + + if ((Integer) data == 1) { + // If we aren't laid out fullscreen or actively in fullscreen mode already, we're going + // to change size and should wait for surfaceChanged() before we return, so the size + // is right back in native code. If we're already laid out fullscreen, though, we're + // not going to change size even if we change decor modes, so we shouldn't wait for + // surfaceChanged() -- which may not even happen -- and should return immediately. + bShouldWait = !bFullscreenLayout; + } else { + // If we're laid out fullscreen (even if the status bar and nav bar are present), + // or are actively in fullscreen, we're going to change size and should wait for + // surfaceChanged before we return, so the size is right back in native code. + bShouldWait = bFullscreenLayout; + } + } + + if (bShouldWait && (SDLActivity.getContext() != null)) { + // We'll wait for the surfaceChanged() method, which will notify us + // when called. That way, we know our current size is really the + // size we need, instead of grabbing a size that's still got + // the navigation and/or status bars before they're hidden. + // + // We'll wait for up to half a second, because some devices + // take a surprisingly long time for the surface resize, but + // then we'll just give up and return. + // + synchronized (SDLActivity.getContext()) { + try { + SDLActivity.getContext().wait(500); + } catch (InterruptedException ie) { + ie.printStackTrace(); + } + } + } + } + } + + return result; + } + + // C functions we call + public static native String nativeGetVersion(); + public static native int nativeSetupJNI(); + public static native int nativeRunMain(String library, String function, Object arguments); + public static native void nativeLowMemory(); + public static native void nativeSendQuit(); + public static native void nativeQuit(); + public static native void nativePause(); + public static native void nativeResume(); + public static native void nativeFocusChanged(boolean hasFocus); + public static native void onNativeDropFile(String filename); + public static native void nativeSetScreenResolution(int surfaceWidth, int surfaceHeight, int deviceWidth, int deviceHeight, float rate); + public static native void onNativeResize(); + public static native void onNativeKeyDown(int keycode); + public static native void onNativeKeyUp(int keycode); + public static native boolean onNativeSoftReturnKey(); + public static native void onNativeKeyboardFocusLost(); + public static native void onNativeMouse(int button, int action, float x, float y, boolean relative); + public static native void onNativeTouch(int touchDevId, int pointerFingerId, + int action, float x, + float y, float p); + public static native void onNativeAccel(float x, float y, float z); + public static native void onNativeClipboardChanged(); + public static native void onNativeSurfaceCreated(); + public static native void onNativeSurfaceChanged(); + public static native void onNativeSurfaceDestroyed(); + public static native String nativeGetHint(String name); + public static native boolean nativeGetHintBoolean(String name, boolean default_value); + public static native void nativeSetenv(String name, String value); + public static native void onNativeOrientationChanged(int orientation); + public static native void nativeAddTouch(int touchId, String name); + public static native void nativePermissionResult(int requestCode, boolean result); + public static native void onNativeLocaleChanged(); + + /** + * This method is called by SDL using JNI. + */ + public static boolean setActivityTitle(String title) { + // Called from SDLMain() thread and can't directly affect the view + return mSingleton.sendCommand(COMMAND_CHANGE_TITLE, title); + } + + /** + * This method is called by SDL using JNI. + */ + public static void setWindowStyle(boolean fullscreen) { + // Called from SDLMain() thread and can't directly affect the view + mSingleton.sendCommand(COMMAND_CHANGE_WINDOW_STYLE, fullscreen ? 1 : 0); + } + + /** + * This method is called by SDL using JNI. + * This is a static method for JNI convenience, it calls a non-static method + * so that is can be overridden + */ + public static void setOrientation(int w, int h, boolean resizable, String hint) + { + if (mSingleton != null) { + mSingleton.setOrientationBis(w, h, resizable, hint); + } + } + + /** + * This can be overridden + */ + public void setOrientationBis(int w, int h, boolean resizable, String hint) + { + int orientation_landscape = -1; + int orientation_portrait = -1; + + /* If set, hint "explicitly controls which UI orientations are allowed". */ + if (hint.contains("LandscapeRight") && hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE; + } else if (hint.contains("LandscapeLeft")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE; + } else if (hint.contains("LandscapeRight")) { + orientation_landscape = ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE; + } + + /* exact match to 'Portrait' to distinguish with PortraitUpsideDown */ + boolean contains_Portrait = hint.contains("Portrait ") || hint.endsWith("Portrait"); + + if (contains_Portrait && hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT; + } else if (contains_Portrait) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_PORTRAIT; + } else if (hint.contains("PortraitUpsideDown")) { + orientation_portrait = ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT; + } + + boolean is_landscape_allowed = (orientation_landscape != -1); + boolean is_portrait_allowed = (orientation_portrait != -1); + int req; /* Requested orientation */ + + /* No valid hint, nothing is explicitly allowed */ + if (!is_portrait_allowed && !is_landscape_allowed) { + if (resizable) { + /* All orientations are allowed */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Fixed window and nothing specified. Get orientation from w/h of created window */ + req = (w > h ? ActivityInfo.SCREEN_ORIENTATION_SENSOR_LANDSCAPE : ActivityInfo.SCREEN_ORIENTATION_SENSOR_PORTRAIT); + } + } else { + /* At least one orientation is allowed */ + if (resizable) { + if (is_portrait_allowed && is_landscape_allowed) { + /* hint allows both landscape and portrait, promote to full sensor */ + req = ActivityInfo.SCREEN_ORIENTATION_FULL_SENSOR; + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } else { + /* Fixed window and both orientations are allowed. Choose one. */ + if (is_portrait_allowed && is_landscape_allowed) { + req = (w > h ? orientation_landscape : orientation_portrait); + } else { + /* Use the only one allowed "orientation" */ + req = (is_landscape_allowed ? orientation_landscape : orientation_portrait); + } + } + } + + Log.v(TAG, "setOrientation() requestedOrientation=" + req + " width=" + w +" height="+ h +" resizable=" + resizable + " hint=" + hint); + mSingleton.setRequestedOrientation(req); + } + + /** + * This method is called by SDL using JNI. + */ + public static void minimizeWindow() { + + if (mSingleton == null) { + return; + } + + Intent startMain = new Intent(Intent.ACTION_MAIN); + startMain.addCategory(Intent.CATEGORY_HOME); + startMain.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK); + mSingleton.startActivity(startMain); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean shouldMinimizeOnFocusLoss() { +/* + if (Build.VERSION.SDK_INT >= 24) { + if (mSingleton == null) { + return true; + } + + if (mSingleton.isInMultiWindowMode()) { + return false; + } + + if (mSingleton.isInPictureInPictureMode()) { + return false; + } + } + + return true; +*/ + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isScreenKeyboardShown() + { + if (mTextEdit == null) { + return false; + } + + if (!mScreenKeyboardShown) { + return false; + } + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + return imm.isAcceptingText(); + + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean supportsRelativeMouse() + { + // DeX mode in Samsung Experience 9.0 and earlier doesn't support relative mice properly under + // Android 7 APIs, and simply returns no data under Android 8 APIs. + // + // This is fixed in Samsung Experience 9.5, which corresponds to Android 8.1.0, and + // thus SDK version 27. If we are in DeX mode and not API 27 or higher, as a result, + // we should stick to relative mode. + // + if (Build.VERSION.SDK_INT < 27 /* Android 8.1 (O_MR1) */ && isDeXMode()) { + return false; + } + + return SDLActivity.getMotionListener().supportsRelativeMouse(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean setRelativeMouseEnabled(boolean enabled) + { + if (enabled && !supportsRelativeMouse()) { + return false; + } + + return SDLActivity.getMotionListener().setRelativeMouseEnabled(enabled); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean sendMessage(int command, int param) { + if (mSingleton == null) { + return false; + } + return mSingleton.sendCommand(command, param); + } + + /** + * This method is called by SDL using JNI. + */ + public static Context getContext() { + return SDL.getContext(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isAndroidTV() { + UiModeManager uiModeManager = (UiModeManager) getContext().getSystemService(UI_MODE_SERVICE); + if (uiModeManager.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION) { + return true; + } + if (Build.MANUFACTURER.equals("MINIX") && Build.MODEL.equals("NEO-U1")) { + return true; + } + if (Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.equals("X96-W")) { + return true; + } + return Build.MANUFACTURER.equals("Amlogic") && Build.MODEL.startsWith("TV"); + } + + public static double getDiagonal() + { + DisplayMetrics metrics = new DisplayMetrics(); + Activity activity = (Activity)getContext(); + if (activity == null) { + return 0.0; + } + activity.getWindowManager().getDefaultDisplay().getMetrics(metrics); + + double dWidthInches = metrics.widthPixels / (double)metrics.xdpi; + double dHeightInches = metrics.heightPixels / (double)metrics.ydpi; + + return Math.sqrt((dWidthInches * dWidthInches) + (dHeightInches * dHeightInches)); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isTablet() { + // If our diagonal size is seven inches or greater, we consider ourselves a tablet. + return (getDiagonal() >= 7.0); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isChromebook() { + if (getContext() == null) { + return false; + } + return getContext().getPackageManager().hasSystemFeature("org.chromium.arc.device_management"); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean isDeXMode() { + if (Build.VERSION.SDK_INT < 24 /* Android 7.0 (N) */) { + return false; + } + try { + final Configuration config = getContext().getResources().getConfiguration(); + final Class configClass = config.getClass(); + return configClass.getField("SEM_DESKTOP_MODE_ENABLED").getInt(configClass) + == configClass.getField("semDesktopModeEnabled").getInt(config); + } catch(Exception ignored) { + return false; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static DisplayMetrics getDisplayDPI() { + return getContext().getResources().getDisplayMetrics(); + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean getManifestEnvironmentVariables() { + try { + if (getContext() == null) { + return false; + } + + ApplicationInfo applicationInfo = getContext().getPackageManager().getApplicationInfo(getContext().getPackageName(), PackageManager.GET_META_DATA); + Bundle bundle = applicationInfo.metaData; + if (bundle == null) { + return false; + } + String prefix = "SDL_ENV."; + final int trimLength = prefix.length(); + for (String key : bundle.keySet()) { + if (key.startsWith(prefix)) { + String name = key.substring(trimLength); + String value = bundle.get(key).toString(); + nativeSetenv(name, value); + } + } + /* environment variables set! */ + return true; + } catch (Exception e) { + Log.v(TAG, "exception " + e.toString()); + } + return false; + } + + // This method is called by SDLControllerManager's API 26 Generic Motion Handler. + public static View getContentView() { + return mLayout; + } + + static class ShowTextInputTask implements Runnable { + /* + * This is used to regulate the pan&scan method to have some offset from + * the bottom edge of the input region and the top edge of an input + * method (soft keyboard) + */ + static final int HEIGHT_PADDING = 15; + + public int x, y, w, h; + + public ShowTextInputTask(int x, int y, int w, int h) { + this.x = x; + this.y = y; + this.w = w; + this.h = h; + + /* Minimum size of 1 pixel, so it takes focus. */ + if (this.w <= 0) { + this.w = 1; + } + if (this.h + HEIGHT_PADDING <= 0) { + this.h = 1 - HEIGHT_PADDING; + } + } + + @Override + public void run() { + RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(w, h + HEIGHT_PADDING); + params.leftMargin = x; + params.topMargin = y; + + if (mTextEdit == null) { + mTextEdit = new DummyEdit(SDL.getContext()); + + mLayout.addView(mTextEdit, params); + } else { + mTextEdit.setLayoutParams(params); + } + + mTextEdit.setVisibility(View.VISIBLE); + mTextEdit.requestFocus(); + + InputMethodManager imm = (InputMethodManager) SDL.getContext().getSystemService(Context.INPUT_METHOD_SERVICE); + imm.showSoftInput(mTextEdit, 0); + + mScreenKeyboardShown = true; + } + } + + /** + * This method is called by SDL using JNI. + */ + public static boolean showTextInput(int x, int y, int w, int h) { + // Transfer the task to the main thread as a Runnable + return mSingleton.commandHandler.post(new ShowTextInputTask(x, y, w, h)); + } + + public static boolean isTextInputEvent(KeyEvent event) { + + // Key pressed with Ctrl should be sent as SDL_KEYDOWN/SDL_KEYUP and not SDL_TEXTINPUT + if (event.isCtrlPressed()) { + return false; + } + + return event.isPrintingKey() || event.getKeyCode() == KeyEvent.KEYCODE_SPACE; + } + + public static boolean handleKeyEvent(View v, int keyCode, KeyEvent event, InputConnection ic) { + int deviceId = event.getDeviceId(); + int source = event.getSource(); + + if (source == InputDevice.SOURCE_UNKNOWN) { + InputDevice device = InputDevice.getDevice(deviceId); + if (device != null) { + source = device.getSources(); + } + } + +// if (event.getAction() == KeyEvent.ACTION_DOWN) { +// Log.v("SDL", "key down: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } else if (event.getAction() == KeyEvent.ACTION_UP) { +// Log.v("SDL", "key up: " + keyCode + ", deviceId = " + deviceId + ", source = " + source); +// } + + // Dispatch the different events depending on where they come from + // Some SOURCE_JOYSTICK, SOURCE_DPAD or SOURCE_GAMEPAD are also SOURCE_KEYBOARD + // So, we try to process them as JOYSTICK/DPAD/GAMEPAD events first, if that fails we try them as KEYBOARD + // + // Furthermore, it's possible a game controller has SOURCE_KEYBOARD and + // SOURCE_JOYSTICK, while its key events arrive from the keyboard source + // So, retrieve the device itself and check all of its sources + if (SDLControllerManager.isDeviceSDLJoystick(deviceId)) { + // Note that we process events with specific key codes here + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (SDLControllerManager.onNativePadDown(deviceId, keyCode) == 0) { + return true; + } + } else if (event.getAction() == KeyEvent.ACTION_UP) { + if (SDLControllerManager.onNativePadUp(deviceId, keyCode) == 0) { + return true; + } + } + } + + if ((source & InputDevice.SOURCE_MOUSE) == InputDevice.SOURCE_MOUSE) { + // on some devices key events are sent for mouse BUTTON_BACK/FORWARD presses + // they are ignored here because sending them as mouse input to SDL is messy + if ((keyCode == KeyEvent.KEYCODE_BACK) || (keyCode == KeyEvent.KEYCODE_FORWARD)) { + switch (event.getAction()) { + case KeyEvent.ACTION_DOWN: + case KeyEvent.ACTION_UP: + // mark the event as handled or it will be handled by system + // handling KEYCODE_BACK by system will call onBackPressed() + return true; + } + } + } + + if (event.getAction() == KeyEvent.ACTION_DOWN) { + if (isTextInputEvent(event)) { + if (ic != null) { + ic.commitText(String.valueOf((char) event.getUnicodeChar()), 1); + } else { + SDLInputConnection.nativeCommitText(String.valueOf((char) event.getUnicodeChar()), 1); + } + } + onNativeKeyDown(keyCode); + return true; + } else if (event.getAction() == KeyEvent.ACTION_UP) { + onNativeKeyUp(keyCode); + return true; + } + + return false; + } + + /** + * This method is called by SDL using JNI. + */ + public static Surface getNativeSurface() { + if (SDLActivity.mSurface == null) { + return null; + } + return SDLActivity.mSurface.getNativeSurface(); + } + + // Input + + /** + * This method is called by SDL using JNI. + */ + public static void initTouch() { + int[] ids = InputDevice.getDeviceIds(); + + for (int id : ids) { + InputDevice device = InputDevice.getDevice(id); + /* Allow SOURCE_TOUCHSCREEN and also Virtual InputDevices because they can send TOUCHSCREEN events */ + if (device != null && ((device.getSources() & InputDevice.SOURCE_TOUCHSCREEN) == InputDevice.SOURCE_TOUCHSCREEN + || device.isVirtual())) { + + int touchDevId = device.getId(); + /* + * Prevent id to be -1, since it's used in SDL internal for synthetic events + * Appears when using Android emulator, eg: + * adb shell input mouse tap 100 100 + * adb shell input touchscreen tap 100 100 + */ + if (touchDevId < 0) { + touchDevId -= 1; + } + nativeAddTouch(touchDevId, device.getName()); + } + } + } + + // Messagebox + + /** Result of current messagebox. Also used for blocking the calling thread. */ + protected final int[] messageboxSelection = new int[1]; + + /** + * This method is called by SDL using JNI. + * Shows the messagebox from UI thread and block calling thread. + * buttonFlags, buttonIds and buttonTexts must have same length. + * @param buttonFlags array containing flags for every button. + * @param buttonIds array containing id for every button. + * @param buttonTexts array containing text for every button. + * @param colors null for default or array of length 5 containing colors. + * @return button id or -1. + */ + public int messageboxShowMessageBox( + final int flags, + final String title, + final String message, + final int[] buttonFlags, + final int[] buttonIds, + final String[] buttonTexts, + final int[] colors) { + + messageboxSelection[0] = -1; + + // sanity checks + + if ((buttonFlags.length != buttonIds.length) && (buttonIds.length != buttonTexts.length)) { + return -1; // implementation broken + } + + // collect arguments for Dialog + + final Bundle args = new Bundle(); + args.putInt("flags", flags); + args.putString("title", title); + args.putString("message", message); + args.putIntArray("buttonFlags", buttonFlags); + args.putIntArray("buttonIds", buttonIds); + args.putStringArray("buttonTexts", buttonTexts); + args.putIntArray("colors", colors); + + // trigger Dialog creation on UI thread + + runOnUiThread(new Runnable() { + @Override + public void run() { + messageboxCreateAndShow(args); + } + }); + + // block the calling thread + + synchronized (messageboxSelection) { + try { + messageboxSelection.wait(); + } catch (InterruptedException ex) { + ex.printStackTrace(); + return -1; + } + } + + // return selected value + + return messageboxSelection[0]; + } + + protected void messageboxCreateAndShow(Bundle args) { + + // TODO set values from "flags" to messagebox dialog + + // get colors + + int[] colors = args.getIntArray("colors"); + int backgroundColor; + int textColor; + int buttonBorderColor; + int buttonBackgroundColor; + int buttonSelectedColor; + if (colors != null) { + int i = -1; + backgroundColor = colors[++i]; + textColor = colors[++i]; + buttonBorderColor = colors[++i]; + buttonBackgroundColor = colors[++i]; + buttonSelectedColor = colors[++i]; + } else { + backgroundColor = Color.TRANSPARENT; + textColor = Color.TRANSPARENT; + buttonBorderColor = Color.TRANSPARENT; + buttonBackgroundColor = Color.TRANSPARENT; + buttonSelectedColor = Color.TRANSPARENT; + } + + // create dialog with title and a listener to wake up calling thread + + final AlertDialog dialog = new AlertDialog.Builder(this).create(); + dialog.setTitle(args.getString("title")); + dialog.setCancelable(false); + dialog.setOnDismissListener(new DialogInterface.OnDismissListener() { + @Override + public void onDismiss(DialogInterface unused) { + synchronized (messageboxSelection) { + messageboxSelection.notify(); + } + } + }); + + // create text + + TextView message = new TextView(this); + message.setGravity(Gravity.CENTER); + message.setText(args.getString("message")); + if (textColor != Color.TRANSPARENT) { + message.setTextColor(textColor); + } + + // create buttons + + int[] buttonFlags = args.getIntArray("buttonFlags"); + int[] buttonIds = args.getIntArray("buttonIds"); + String[] buttonTexts = args.getStringArray("buttonTexts"); + + final SparseArray