diff --git a/android/app/build.gradle b/android/app/build.gradle index 342b605..060758e 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -27,6 +27,49 @@ if (prebuiltDir == null) { def questshockVersionName = project.hasProperty('questshockVersionName') ? project.property('questshockVersionName') : '0.0.0-dev' def questshockVersionCode = project.hasProperty('questshockVersionCode') ? project.property('questshockVersionCode').toInteger() : 1 +// Auto-refreshes the staged, patched engine/ copy and prebuilt libraries +// (see build-image/prepare-android-project.sh --host-paths, and the +// engineDir/prebuiltDir properties read above) so a plain Android Studio +// build/run can never silently compile against a stale scratch copy after +// engine/ or android/engine-patches/ change - previously a manual, easy to +// forget re-run. inputs/outputs are declared so Gradle skips the (Docker- +// invoking, not free) step entirely when nothing relevant actually changed, +// keeping pure-Java edit/run cycles fast. +def repoRoot = file("${projectDir}/../..") +def stageEngineTask = tasks.register("stageEngine", Exec) { + group = "build setup" + description = "Refreshes the patched engine/ scratch copy and prebuilt libraries for a native Android Studio build (build-image/prepare-android-project.sh --host-paths)." + workingDir repoRoot + commandLine "./run-image.sh", "bash", "build-image/prepare-android-project.sh", "--host-paths" + + // /opt/prebuilt/android only ever exists inside the build-image + // container itself (baked in at image-build time, never on the host - + // see build-image/Dockerfile). Its presence means this build is + // build-apk.sh's own gradlew call, running INSIDE that container, which + // already staged everything itself in container mode before invoking + // gradlew - re-running this task there would try to `docker run` from + // inside a container with no docker socket, breaking make apk/CI. Only + // a genuine host-side Android Studio build (where this path is absent) + // needs this task. + onlyIf { !file('/opt/prebuilt/android').isDirectory() } + + inputs.dir("${repoRoot}/engine") + inputs.dir("${projectDir}/../engine-patches") + inputs.file("${repoRoot}/build-image/Dockerfile") + outputs.dir("${repoRoot}/build/android-engine") + outputs.dir("${repoRoot}/build/android-prebuilt") + outputs.file("${projectDir}/../engine.properties") +} + +// preBuild is what every variant's compile/native-build tasks already +// transitively depend on, regardless of AGP version's exact CMake task +// naming - the simplest reliable hook to run before any of them. +afterEvaluate { + tasks.named("preBuild").configure { + dependsOn stageEngineTask + } +} + android { namespace "de.ladkau.questshock" // compileSdk/buildToolsVersion/ndkVersion must all match what @@ -67,6 +110,11 @@ android { // which would otherwise miss our custom-installed SDL2 (see // build-image/Dockerfile's SDL2_mixer build, which hit the // exact same thing). + // -Wl,-z,max-page-size=16384: NDK 26 doesn't 16 KB-align ELF + // LOAD segments by default (only automatic in NDK 28+) - see + // build-image/Dockerfile's ANDROID_16KB_LDFLAGS, which does + // the same for the prebuilt SDL2/SDL2_mixer/fluidsynth-lite/ + // gl4es .so's this links against. arguments "-DENABLE_SDL2=ON", "-DENABLE_SOUND=BUNDLED", "-DENABLE_FLUIDSYNTH=BUNDLED", \ "-DANDROID_PREBUILT_DIR=${prebuiltDir}", \ "-DCMAKE_PREFIX_PATH=${prebuiltDir}/sdl2", \ @@ -74,6 +122,7 @@ android { "-DCMAKE_FIND_ROOT_PATH_MODE_PACKAGE=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_LIBRARY=BOTH", \ "-DCMAKE_FIND_ROOT_PATH_MODE_INCLUDE=BOTH", \ + "-DCMAKE_SHARED_LINKER_FLAGS=-Wl,-z,max-page-size=16384", \ "-DANDROID_EXTRA_SOURCES=${projectDir}/src/main/cpp/questshock_native.c" abiFilters 'arm64-v8a' } diff --git a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java index 3f63714..1f32120 100644 --- a/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java +++ b/android/app/src/main/java/de/ladkau/questshock/QuestShockActivity.java @@ -2,10 +2,12 @@ package de.ladkau.questshock; import android.Manifest; import android.app.AlertDialog; +import android.content.Context; import android.content.pm.PackageManager; import android.content.res.AssetManager; import android.os.Bundle; import android.util.Log; +import android.view.SurfaceHolder; import androidx.core.app.ActivityCompat; import java.io.File; import java.io.FileOutputStream; @@ -13,16 +15,26 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import org.libsdl.app.SDLActivity; +import org.libsdl.app.SDLSurface; /** - * 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. + * Prepares /sdcard/questshock/ the way Shockolate's plain relative-path + * file I/O ("res/data/...", confirmed via grep - none of it goes through + * SDL_RWops, so Android's asset-manager fallback for SDL_RWFromFile + * doesn't apply here) expects to find it - see android/engine-patches/ for + * why this is done from Java instead of patching engine/ itself, which is + * never modified. + * + * super.onCreate() (SDLActivity's) must always run first and unconditionally + * - Android throws SuperNotCalledException otherwise, checked right after + * onCreate() returns, regardless of what this subclass does afterwards. + * SDLActivity's own onCreate() only loads libraries, sets up JNI and the + * surface - the actual native SDL_main thread doesn't start until later, from + * one of several lifecycle paths (onResume(), onWindowFocusChanged(), and + * SDLSurface.surfaceChanged() - see GameSurface below for why that last one + * needs its own fix) - so it's safe to do our own checks (and set + * SDLActivity.mBrokenLibraries, which most - but not all - of those paths + * gate on) afterwards. */ public class QuestShockActivity extends SDLActivity { private static final String TAG = "QuestShock"; @@ -45,8 +57,6 @@ public class QuestShockActivity extends SDLActivity { // 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[] { @@ -57,9 +67,52 @@ public class QuestShockActivity extends SDLActivity { }; } + // SDLSurface.surfaceChanged() (org/libsdl/app/, vendored from SDL2's own + // template) starts the native SDL thread directly - unlike onResume()/ + // onWindowFocusChanged(), it never checks SDLActivity.mBrokenLibraries + // first. Since surfaceChanged() fires on essentially every launch + // regardless of that flag, setting mBrokenLibraries alone (see onCreate()/ + // setUpGameDirAndContinue()) does NOT actually stop the engine from + // starting - confirmed on-device: the missing-assets crash still happened + // with mBrokenLibraries set, from exactly this path. Route through a + // subclass that adds the missing check instead of patching the vendored + // file directly. + private static class GameSurface extends SDLSurface { + GameSurface(Context context) { + super(context); + } + + @Override + public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { + if (SDLActivity.mBrokenLibraries) { + return; + } + super.surfaceChanged(holder, format, width, height); + } + } + + @Override + protected SDLSurface createSDLSurface(Context context) { + return new GameSurface(context); + } + @Override protected void onCreate(Bundle savedInstanceState) { - mSavedInstanceState = savedInstanceState; + super.onCreate(savedInstanceState); + if (SDLActivity.mBrokenLibraries) { + // SDLActivity's own onCreate() already showed its "SDL Error" + // dialog for this - nothing left for us to do. + return; + } + // Provisionally block the native engine from starting - cleared only + // once setUpGameDirAndContinue() confirms game data is present. Must + // happen before requestPermissions() below: the storage-permission + // dialog closing can fire onWindowFocusChanged(true) - which starts + // the native SDLThread - before the async onRequestPermissionsResult() + // callback (which is what actually calls setUpGameDirAndContinue()) + // gets a chance to run, so mBrokenLibraries has to already be true + // going into that race, not set afterwards. + SDLActivity.mBrokenLibraries = true; if (ActivityCompat.checkSelfPermission(this, Manifest.permission.WRITE_EXTERNAL_STORAGE) != PackageManager.PERMISSION_GRANTED) { ActivityCompat.requestPermissions(this, @@ -102,19 +155,28 @@ public class QuestShockActivity extends SDLActivity { // and, on a fresh install with no game data copied in yet, that // fails deep inside startup (init_popups(), which doesn't NULL-check // the load) as a hard native crash instead of a message. Catch the - // missing-data case here instead, before super.onCreate() ever - // starts Shockolate's native main(). + // missing-data case here instead, before Shockolate's native + // SDL_main ever starts. if (!isNonEmptyDir(new File(gameDir, "res/data")) || !isNonEmptyDir(new File(gameDir, "res/sound"))) { + // mBrokenLibraries is already true (set in onCreate()) - leave it + // that way. Every path that starts the native SDL_main thread + // (onWindowFocusChanged(), resumeNativeThread(), etc.) already + // checks this flag before doing anything native, so this + // reliably prevents the engine from starting without having to + // duplicate all of SDLActivity's own lifecycle guards ourselves. showMissingAssetsDialog(); return; } // chdir() is process-wide, not per-thread - already in effect for // every thread (including the one that will run Shockolate's own - // SDL_main) by the time super.onCreate() below starts it. + // SDL_main) by the time the native thread actually starts. Done + // before clearing mBrokenLibraries below so the engine can never + // start pre-chdir. nativeChdir(GAME_DIR); - super.onCreate(mSavedInstanceState); + // Assets confirmed present - safe to let the native engine start now. + SDLActivity.mBrokenLibraries = false; } private static boolean isNonEmptyDir(File dir) { diff --git a/android/engine-patches/05-android-audio-driver.patch b/android/engine-patches/05-android-audio-driver.patch new file mode 100644 index 0000000..302d0a3 --- /dev/null +++ b/android/engine-patches/05-android-audio-driver.patch @@ -0,0 +1,20 @@ +--- a/src/MacSrc/Shock.c ++++ b/src/MacSrc/Shock.c +@@ -162,6 +162,17 @@ + void InitSDL() { + SDL_SetHint(SDL_HINT_NO_SIGNAL_HANDLERS, "1"); + SDL_SetHint(SDL_HINT_RENDER_DRIVER, "opengl"); ++#ifdef __ANDROID__ ++ // SDL2 2.28.5's AAudio backend (Android's default since API 26) only ++ // ever allows one open non-capture (playback) device at a time - it ++ // keeps a single static handle and asserts on a second open ++ // ('SDL_assert((audioDevice == NULL) || iscapture)' in ++ // src/audio/aaudio/SDL_aaudio.c). SDLSound.c opens two: one directly via ++ // SDL_OpenAudioDevice() for cutscene audio, one via Mix_OpenAudio() for ++ // SFX/MIDI. The older OpenSL ES backend has no such limitation, so force ++ // it instead of AAudio. ++ SDL_SetHint(SDL_HINT_AUDIODRIVER, "openslES"); ++#endif + if (SDL_Init(SDL_INIT_VIDEO | SDL_INIT_TIMER | SDL_INIT_AUDIO) < 0) { + DEBUG("%s: Init failed", __FUNCTION__); + } diff --git a/build-image/Dockerfile b/build-image/Dockerfile index bb94634..3f5f41b 100644 --- a/build-image/Dockerfile +++ b/build-image/Dockerfile @@ -46,6 +46,13 @@ ARG ANDROID_COMPILE_SDK_VERSION=34 ARG ANDROID_BUILD_TOOLS_VERSION=34.0.0 ARG ANDROID_NDK_VERSION=26.1.10909125 ARG ANDROID_CMAKE_VERSION=3.22.1 +# NDK 26 doesn't yet default to 16 KB-aligned ELF LOAD segments (that only +# became automatic in NDK 28+) - Google requires this for Play Store +# submissions targeting Android 15+ as of Nov 2025, and it's cheap/harmless +# to do regardless of Play Store status. Passed to every Android shared-lib +# CMake configure below, and to the engine's own build via +# android/app/build.gradle's externalNativeBuild.cmake.arguments. +ARG ANDROID_16KB_LDFLAGS="-Wl,-z,max-page-size=16384" # GL4ES (https://github.com/ptitSeb/gl4es) - translates the engine's # desktop-style immediate-mode OpenGL calls into real GLES/EGL calls, so # engine/src/MacSrc/OpenGL.cc needs no immediate-mode rewrite on Android. @@ -182,6 +189,7 @@ RUN curl -sSLO "https://www.libsdl.org/release/SDL2-${ANDROID_SDL2_VERSION}.tar. -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DCMAKE_INSTALL_PREFIX=/opt/prebuilt/android/sdl2 -DBUILD_SHARED_LIBS=ON \ -DSDL_STATIC=OFF \ + -DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \ && cmake --build build-sdl2-android -j"$(nproc)" \ && cmake --install build-sdl2-android \ && rm -rf "SDL2-${ANDROID_SDL2_VERSION}" "SDL2-${ANDROID_SDL2_VERSION}.tar.gz" build-sdl2-android @@ -207,6 +215,7 @@ RUN curl -sSLO "https://www.libsdl.org/projects/SDL_mixer/release/SDL2_mixer-${A -DSDL2MIXER_FLAC=OFF -DSDL2MIXER_GME=OFF -DSDL2MIXER_MOD=OFF \ -DSDL2MIXER_MP3=OFF -DSDL2MIXER_MIDI=OFF -DSDL2MIXER_OPUS=OFF -DSDL2MIXER_VORBIS=OFF \ -DSDL2MIXER_WAVPACK=OFF \ + -DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \ && cmake --build build-sdl2mixer-android -j"$(nproc)" \ && cmake --install build-sdl2mixer-android \ && rm -rf "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}" "SDL2_mixer-${ANDROID_SDL2_MIXER_VERSION}.tar.gz" build-sdl2mixer-android @@ -222,6 +231,7 @@ RUN git clone https://github.com/EtherTyper/fluidsynth-lite.git fluidsynth-lite- && cmake -S fluidsynth-lite-android -B build-fluidsynth-android \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ + -DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \ && cmake --build build-fluidsynth-android -j"$(nproc)" \ && mkdir -p /opt/prebuilt/android/fluidsynth-lite/lib /opt/prebuilt/android/fluidsynth-lite/include \ && cp -a build-fluidsynth-android/src/libfluidsynth.so* /opt/prebuilt/android/fluidsynth-lite/lib/ \ @@ -254,6 +264,7 @@ RUN git clone --branch "v${ANDROID_GL4ES_VERSION}" --depth 1 \ -DCMAKE_TOOLCHAIN_FILE="${ANDROID_NDK_TOOLCHAIN}" \ -DANDROID_ABI="${ANDROID_ABI}" -DANDROID_PLATFORM="android-${ANDROID_PLATFORM_VERSION}" \ -DANDROID=ON -DUSE_ANDROID_LOG=ON -DSTATICLIB=OFF \ + -DCMAKE_SHARED_LINKER_FLAGS="${ANDROID_16KB_LDFLAGS}" \ && cmake --build build-gl4es-android -j"$(nproc)" \ && mkdir -p /opt/prebuilt/android/gl4es/lib /opt/prebuilt/android/gl4es/include \ && cp -a gl4es-android/lib/libGL.so.1 /opt/prebuilt/android/gl4es/lib/libGL.so \ diff --git a/res/screenshots/com.oculus.vrshell-20260723-171121.jpg b/res/screenshots/com.oculus.vrshell-20260723-171121.jpg new file mode 100644 index 0000000..acf6b88 Binary files /dev/null and b/res/screenshots/com.oculus.vrshell-20260723-171121.jpg differ diff --git a/res/screenshots/com.oculus.vrshell-20260723-172051.jpg b/res/screenshots/com.oculus.vrshell-20260723-172051.jpg new file mode 100644 index 0000000..a5f8bf9 Binary files /dev/null and b/res/screenshots/com.oculus.vrshell-20260723-172051.jpg differ diff --git a/res/screenshots/com.oculus.vrshell-20260723-175138.jpg b/res/screenshots/com.oculus.vrshell-20260723-175138.jpg new file mode 100644 index 0000000..ed1d467 Binary files /dev/null and b/res/screenshots/com.oculus.vrshell-20260723-175138.jpg differ diff --git a/res/screenshots/com.oculus.vrshell-20260723-185839.jpg b/res/screenshots/com.oculus.vrshell-20260723-185839.jpg new file mode 100644 index 0000000..99b8aae Binary files /dev/null and b/res/screenshots/com.oculus.vrshell-20260723-185839.jpg differ