From 44cfe7ac8e220483a043fc5cf6e9394873cf4281 Mon Sep 17 00:00:00 2001 From: Cameron Aaron <5474198+cameronaaron@users.noreply.github.com> Date: Sun, 6 Sep 2026 10:24:30 -0700 Subject: [PATCH] Ship Swappy's Java classes to stop in-memory DEX loading Godot statically links Google's Swappy frame pacer and enables it by default. On every swapchain creation -- on the render thread, inside GodotLib.step() -- SwappyCommon constructs a SwappyDisplayManager, whose native constructor calls gamesdk::loadClass(). That helper asks the app's class loader first and, only when it throws, falls back to a classes.dex blob linked into libgodot_android.so, loaded via InMemoryDexClassLoader. Godot's AAR ships no com.google.androidgamesdk classes, so the fallback fired on every launch. That is dynamic code loading: hardened Android builds (GrapheneOS "DCL via memory") reject it with a SecurityException, and Swappy calls CallObjectMethod on the null result without checking for the pending exception, aborting the render thread. Vendoring the two classes Swappy looks up makes its first lookup succeed, so the DEX fallback is never reached and frame pacing keeps working. Both files are byte-identical to upstream AGDK below their added headers, and their signatures were checked against the DEX extracted from Godot's own .so. Keep rules are added because R8 cannot see JNI RegisterNatives bindings, and the build already referenced a proguard-rules.pro that did not exist. Verified on an emulator by reading Swappy's own log: without these classes it prints "Using internal com/google/androidgamesdk/SwappyDisplayManager class from dex bytes."; with them that line is gone. The instrumented test asserts the same branch condition and fails with ClassNotFoundException if the classes are dropped. Co-Authored-By: Claude Opus 5 --- THIRD-PARTY-NOTICES.md | 9 + app/proguard-rules.pro | 13 + .../godot/SwappyClassLoadingTest.kt | 97 +++++++ .../androidgamesdk/ChoreographerCallback.java | 99 +++++++ .../androidgamesdk/SwappyDisplayManager.java | 244 ++++++++++++++++++ 5 files changed, 462 insertions(+) create mode 100644 app/proguard-rules.pro create mode 100644 app/src/androidTest/java/com/openbubbles/openpigeon/godot/SwappyClassLoadingTest.kt create mode 100644 app/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java create mode 100644 app/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index 58d7f36a..4e955840 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -35,6 +35,15 @@ be removed. **Box2D** - MIT License `app/src/main/cpp/box2d` (git submodule) +**Android Game Development Kit (Swappy frame pacing)** - Apache License 2.0 +`app/src/main/java/com/google/androidgamesdk/` + + +`ChoreographerCallback.java` and `SwappyDisplayManager.java` are vendored +verbatim from AGDK. Godot statically links Swappy's native half and looks these +classes up by name; shipping them keeps Swappy off its in-memory DEX fallback, +which hardened Android builds block. See the header comments in those files. + --- ## Creative Commons assets diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 00000000..cda9a474 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,13 @@ +# Swappy frame pacing (Android Game Development Kit). +# +# Godot statically links Swappy into libgodot_android.so. Swappy's native side +# resolves these two classes by exact name through the app class loader, and +# binds their members with JNI GetMethodID/RegisterNatives -- none of which R8 +# can see. If they are shrunk, renamed, or have members removed, Swappy falls +# back to loading its own classes.dex out of the .so via InMemoryDexClassLoader, +# which hardened Android builds (GrapheneOS "DCL via memory") reject with a +# SecurityException that aborts the render thread inside GodotLib.step(). +# +# Upstream equivalent: games-frame-pacing/extras/lib-proguard-rules.txt +-keep public class com.google.androidgamesdk.ChoreographerCallback { *; } +-keep public class com.google.androidgamesdk.SwappyDisplayManager { *; } diff --git a/app/src/androidTest/java/com/openbubbles/openpigeon/godot/SwappyClassLoadingTest.kt b/app/src/androidTest/java/com/openbubbles/openpigeon/godot/SwappyClassLoadingTest.kt new file mode 100644 index 00000000..16c8005f --- /dev/null +++ b/app/src/androidTest/java/com/openbubbles/openpigeon/godot/SwappyClassLoadingTest.kt @@ -0,0 +1,97 @@ +package com.openbubbles.openpigeon.godot + +import android.app.Activity +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import org.junit.Assert.assertTrue +import org.junit.Test +import org.junit.runner.RunWith +import java.lang.reflect.Method +import java.lang.reflect.Modifier + +/** + * Regression test for the GrapheneOS `memory_DCL` abort inside `GodotLib.step()`. + * + * Godot statically links Google's Swappy frame pacer and enables it by default + * (`display/window/frame_pacing/android/enable_frame_pacing`). Swappy's native side + * resolves its Java helpers with `gamesdk::loadClass()`, which asks the *activity's* + * class loader first and, only when that throws, falls back to a `classes.dex` blob + * linked into `libgodot_android.so` loaded through `dalvik.system.InMemoryDexClassLoader`. + * + * That fallback is dynamic code loading. Hardened Android builds reject it with a + * `SecurityException`, and Swappy calls `CallObjectMethod` on the null result without + * checking for the pending exception, so the render thread aborts. + * + * The property that keeps the fallback unreachable is exactly this: the names Swappy + * passes must already resolve through our own class loader, with the members it binds + * via `GetMethodID`/`RegisterNatives` intact. Note the *slash-separated* names -- that + * is verbatim what Swappy passes to `ClassLoader.loadClass`, and ART accepts it because + * `BaseDexClassLoader` only ever does `name.replace('.', '/')`. + */ +@RunWith(AndroidJUnit4::class) +class SwappyClassLoadingTest { + + private val classLoader: ClassLoader + get() = InstrumentationRegistry.getInstrumentation().targetContext.classLoader + + /** `ChoreographerThread::CT_CLASS` in games-frame-pacing/common/ChoreographerThread.cpp. */ + @Test + fun choreographerCallbackResolvesWithoutDynamicCodeLoading() { + val cls = classLoader.loadClass("com/google/androidgamesdk/ChoreographerCallback") + + // JavaChoreographerThread::JavaChoreographerThread() binds these. + cls.getConstructor(java.lang.Long.TYPE) + cls.getMethod("postFrameCallback") + cls.getMethod("terminate") + + // Registered natively by ChoreographerThread::CTNativeMethods. + assertNative( + cls.getDeclaredMethod( + "nOnChoreographer", java.lang.Long.TYPE, java.lang.Long.TYPE + ) + ) + } + + /** + * `SwappyDisplayManager::SDM_CLASS`. This is the one that actually fires on current + * Android: `SwappyCommon` gates the choreographer callback on the NDK choreographer + * being unavailable, but gates the display manager on `usesMinSdkOrLater()` (SDK >= 28) + * rather than `useSwappyDisplayManager()` (which excludes SDK >= 31), so it is + * constructed on every modern device. + */ + @Test + fun swappyDisplayManagerResolvesWithoutDynamicCodeLoading() { + val cls = classLoader.loadClass("com/google/androidgamesdk/SwappyDisplayManager") + + // SwappyDisplayManager::SwappyDisplayManager() binds these. + cls.getConstructor(java.lang.Long.TYPE, Activity::class.java) + cls.getMethod("setPreferredDisplayModeId", Integer.TYPE) + cls.getMethod("terminate") + + // Registered natively by SwappyDisplayManager::SDMNativeMethods. + assertNative( + cls.getDeclaredMethod( + "nSetSupportedRefreshPeriods", + java.lang.Long.TYPE, + LongArray::class.java, + IntArray::class.java + ) + ) + assertNative( + cls.getDeclaredMethod( + "nOnRefreshPeriodChanged", + java.lang.Long.TYPE, + java.lang.Long.TYPE, + java.lang.Long.TYPE, + java.lang.Long.TYPE + ) + ) + } + + private fun assertNative(method: Method) { + assertTrue( + "${method.name} must stay native for RegisterNatives to bind it", + Modifier.isNative(method.modifiers) + ) + } +} diff --git a/app/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java b/app/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java new file mode 100644 index 00000000..5ac2775c --- /dev/null +++ b/app/src/main/java/com/google/androidgamesdk/ChoreographerCallback.java @@ -0,0 +1,99 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Vendored verbatim from the Android Game Development Kit (AGDK), Swappy frame +// pacing "extras" module: +// frameworks/opt/gamesdk/games-frame-pacing/extras/src/main/java/ +// com/google/androidgamesdk/ChoreographerCallback.java +// +// WHY THIS FILE EXISTS IN AN APP THAT NEVER CALLS IT DIRECTLY: +// +// Godot statically links Swappy into libgodot_android.so and enables it by +// default (display/window/frame_pacing/android/enable_frame_pacing). Swappy's +// native side resolves its Java helpers with gamesdk::loadClass(), which: +// +// 1. tries activity.getClassLoader().loadClass("com/google/androidgamesdk/X") +// 2. and ONLY on failure falls back to a classes.dex blob linked into the +// .so, loaded via dalvik.system.InMemoryDexClassLoader. +// +// Step 2 is dynamic code loading. Hardened Android builds (e.g. GrapheneOS with +// the per-app "DCL via memory" restriction) throw SecurityException from +// InMemoryDexClassLoader's constructor; Swappy does not check for a pending +// exception before its next JNI call, so the process aborts on the render +// thread inside GodotLib.step(). +// +// Shipping these classes in our own APK makes step 1 succeed, so the in-memory +// DEX path is never taken. Frame pacing keeps working, on stock and hardened +// Android alike. Do not rename, move, or shrink these classes -- Swappy looks +// them up by exact name and binds their members via JNI RegisterNatives. +// See app/proguard-rules.pro and SwappyClassLoadingTest. + +package com.google.androidgamesdk; + +import android.os.Handler; +import android.os.Looper; +import android.view.Choreographer; +import android.util.Log; + + +public class ChoreographerCallback implements Choreographer.FrameCallback { + private static final String LOG_TAG = "ChoreographerCallback"; + private long mCookie; + private LooperThread mLooper; + + private class LooperThread extends Thread { + public Handler mHandler; + + public void run() { + Log.i(LOG_TAG, "Starting looper thread"); + Looper.prepare(); + mHandler = new Handler(); + Looper.loop(); + Log.i(LOG_TAG, "Terminating looper thread"); + } + } + + public ChoreographerCallback(long cookie) { + mCookie = cookie; + mLooper = new LooperThread(); + mLooper.start(); + } + + public void postFrameCallback() { + mLooper.mHandler.post(new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback(ChoreographerCallback.this); + } + }); + } + + public void postFrameCallbackDelayed(long delayMillis) { + Choreographer.getInstance().postFrameCallbackDelayed(this, delayMillis); + } + + public void terminate() { + mLooper.mHandler.getLooper().quit(); + } + + @Override + public void doFrame(long frameTimeNanos) { + nOnChoreographer(mCookie, frameTimeNanos); + } + + public native void nOnChoreographer(long cookie, long frameTimeNanos); + +} diff --git a/app/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java b/app/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java new file mode 100644 index 00000000..c3e61694 --- /dev/null +++ b/app/src/main/java/com/google/androidgamesdk/SwappyDisplayManager.java @@ -0,0 +1,244 @@ +/* + * Copyright 2018 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +// Vendored verbatim from the Android Game Development Kit (AGDK), Swappy frame +// pacing "extras" module: +// frameworks/opt/gamesdk/games-frame-pacing/extras/src/main/java/ +// com/google/androidgamesdk/SwappyDisplayManager.java +// +// This is the class that actually triggers the in-memory DEX load on current +// Android. SwappyCommon::SwappyCommon() gates the ChoreographerCallback on the +// NDK Choreographer being unavailable (so it is unused from API 30 up), but it +// gates the display manager on usesMinSdkOrLater() -- SDK >= 28 -- rather than +// on useSwappyDisplayManager(), which excludes SDK >= 31. So Swappy constructs +// SwappyDisplayManager on every modern device and calls gamesdk::loadClass() +// for it on the render thread. +// +// See ChoreographerCallback.java in this package for the full explanation of +// why both classes are vendored here. + +package com.google.androidgamesdk; + +import android.annotation.TargetApi; +import android.app.Activity; +import android.content.ComponentName; +import android.content.pm.ActivityInfo; +import android.content.pm.PackageManager; +import android.hardware.display.DisplayManager; +import android.os.Build; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; +import android.view.Display; +import android.view.Window; +import android.view.WindowManager; + +import java.util.concurrent.locks.Condition; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; + +import static android.app.NativeActivity.META_DATA_LIB_NAME; + +public class SwappyDisplayManager implements DisplayManager.DisplayListener { + final private String LOG_TAG = "SwappyDisplayManager"; + final private boolean DEBUG = false; + final private long ONE_MS_IN_NS = 1000000; + final private long ONE_S_IN_NS = ONE_MS_IN_NS * 1000; + + private long mCookie; + private Activity mActivity; + private DisplayManager mDisplayManager; + private WindowManager mWindowManager; + private Display.Mode mCurrentMode; + + private LooperThread mLooper; + + private class LooperThread extends Thread { + public Handler mHandler; + private Lock mLock = new ReentrantLock(); + private Condition mCondition = mLock.newCondition(); + + @Override + public void start() { + mLock.lock(); + super.start(); + try { + mCondition.await(); + } catch (InterruptedException e) { + e.printStackTrace(); + } + mLock.unlock(); + + } + + public void run() { + Log.i(LOG_TAG, "Starting looper thread"); + + mLock.lock(); + Looper.prepare(); + mHandler = new Handler(); + mCondition.signal(); + mLock.unlock(); + + Looper.loop(); + + Log.i(LOG_TAG, "Terminating looper thread"); + } + } + + @TargetApi(Build.VERSION_CODES.M) + private boolean modeMatchesCurrentResolution(Display.Mode mode) { + return mode.getPhysicalHeight() == mCurrentMode.getPhysicalHeight() && + mode.getPhysicalWidth() == mCurrentMode.getPhysicalWidth(); + + } + + // Called from native SwappyDisplayManager.cpp + public SwappyDisplayManager(long cookie, Activity activity) { + // Load the native library for cases where an NDK application is running + // without a java componenet + try { + ActivityInfo ai = activity.getPackageManager().getActivityInfo( + activity.getIntent().getComponent(), PackageManager.GET_META_DATA); + if (ai.metaData != null) { + String nativeLibName = ai.metaData.getString(META_DATA_LIB_NAME); + if (nativeLibName != null) { + System.loadLibrary(nativeLibName); + } + } + } catch (java.lang.Throwable e) { + Log.e(LOG_TAG, e.getMessage()); + } + + mCookie = cookie; + mActivity = activity; + + mDisplayManager = mActivity.getSystemService(DisplayManager.class); + mWindowManager = mActivity.getSystemService(WindowManager.class); + Display display = mWindowManager.getDefaultDisplay(); + mCurrentMode = display.getMode(); + updateSupportedRefreshRates(display); + + // Register display listener callbacks + synchronized(this) { + mLooper = new LooperThread(); + mLooper.start(); + mDisplayManager.registerDisplayListener(this, mLooper.mHandler); + } + } + + private void updateSupportedRefreshRates(Display display) { + Display.Mode[] supportedModes = display.getSupportedModes(); + int totalModes = 0; + for (int i = 0; i < supportedModes.length; i++) { + if (!modeMatchesCurrentResolution(supportedModes[i])) { + continue; + } + totalModes++; + } + + long[] supportedRefreshPeriods = new long[totalModes]; + int[] supportedDisplayModeIds = new int[totalModes]; + totalModes = 0; + for (int i = 0; i < supportedModes.length; i++) { + if (!modeMatchesCurrentResolution(supportedModes[i])) { + continue; + } + supportedRefreshPeriods[totalModes] = + (long) (ONE_S_IN_NS / supportedModes[i].getRefreshRate()); + supportedDisplayModeIds[totalModes] = supportedModes[i].getModeId(); + totalModes++; + + } + // Call down to native to set the supported refresh rates + nSetSupportedRefreshPeriods(mCookie, supportedRefreshPeriods, supportedDisplayModeIds); + } + + // Called from native SwappyDisplayManager.cpp + public void setPreferredDisplayModeId(final int modeId) { + mActivity.runOnUiThread(new Runnable() { + @Override + public void run() { + Window w = mActivity.getWindow(); + WindowManager.LayoutParams l = w.getAttributes(); + if (DEBUG) { + Log.v(LOG_TAG, "set preferredDisplayModeId to " + modeId); + } + l.preferredDisplayModeId = modeId; + + + w.setAttributes(l); + } + }); + } + + // Called from native SwappyDisplayManager.cpp + public void terminate() { + mDisplayManager.unregisterDisplayListener(this); + mLooper.mHandler.getLooper().quit(); + } + + @Override + public void onDisplayAdded(int displayId) { + + } + + @Override + public void onDisplayRemoved(int displayId) { + + } + + @Override + public void onDisplayChanged(int displayId) { + synchronized(this) { + Display display = mWindowManager.getDefaultDisplay(); + float newRefreshRate = display.getRefreshRate(); + Display.Mode newMode = display.getMode(); + boolean resolutionChanged = + (newMode.getPhysicalWidth() != mCurrentMode.getPhysicalWidth()) | + (newMode.getPhysicalHeight() != mCurrentMode.getPhysicalHeight()); + boolean refreshRateChanged = (newRefreshRate != mCurrentMode.getRefreshRate()); + mCurrentMode = newMode; + + if (resolutionChanged) { + updateSupportedRefreshRates(display); + } + + if (refreshRateChanged) { + final long appVsyncOffsetNanos = display.getAppVsyncOffsetNanos(); + final long vsyncPresentationDeadlineNanos = + mWindowManager.getDefaultDisplay().getPresentationDeadlineNanos(); + + final long vsyncPeriodNanos = (long)(ONE_S_IN_NS / newRefreshRate); + final long sfVsyncOffsetNanos = + vsyncPeriodNanos - (vsyncPresentationDeadlineNanos - ONE_MS_IN_NS); + + nOnRefreshPeriodChanged(mCookie, + vsyncPeriodNanos, + appVsyncOffsetNanos, + sfVsyncOffsetNanos); + } + } + } + + private native void nSetSupportedRefreshPeriods(long cookie, + long[] refreshPeriods, + int[] modeIds); + private native void nOnRefreshPeriodChanged(long cookie, + long refreshPeriod, + long appOffset, + long sfOffset); +}