Ship Swappy's Java classes to stop in-memory DEX loading - #50
Open
cameronaaron wants to merge 1 commit into
Open
Ship Swappy's Java classes to stop in-memory DEX loading#50cameronaaron wants to merge 1 commit into
cameronaaron wants to merge 1 commit into
Conversation
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 <noreply@anthropic.com>
Collaborator
|
Reviewing the files in this PR it seems like most of the changes were made for testing/compatibility but are not needed in the PR. These files would be ChoreographerCallback.java and SwappyClassLoadingTest.kt and proguard-rules.pro. The real fix seems to be in SwappyDisplayManager.java and its associated ThirdPartyNotice update. |
Collaborator
|
You also note that you haven't tested this at all on a GrapheneOS device which you should probably do before this PR is merged |
|
if y'all build an apk I can test it |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The bug
On hardened Android builds (GrapheneOS with the per-app "DCL via memory" restriction), the
:godotprocess aborts on the render thread:Root cause
Not our code — Godot statically links Google's Swappy frame pacer and enables it by default (
display/window/frame_pacing/android/enable_frame_pacing, whichproject.godotdoes not override).step()on the render thread,rendering_device_driver_vulkan.cppcallsSwappyVk_initAndGetRefreshCycleDuration().SwappyCommon's constructor gates the display manager onusesMinSdkOrLater()(SDK >= 28) rather thanuseSwappyDisplayManager()(which excludes SDK >= 31), so it constructs aSwappyDisplayManagereven on current Android.gamesdk::loadClass(), which tries the app's class loader first and falls back to aclasses.dexblob linked intolibgodot_android.so, loaded viaInMemoryDexClassLoader.com.google.androidgamesdkclasses, so step 3 always takes the fallback.That fallback is dynamic code loading. GrapheneOS rejects it, and Swappy calls
CallObjectMethodon the null result without checking for the pending exception — so the process aborts rather than degrading.Confirmed against the binary, not inferred:
libgodot_android.so(4.7.0.stable) contains the literaldalvik/system/InMemoryDexClassLoaderplusswappy::symbols, and an embedded DEX at offset 71136592 holding exactlySwappyDisplayManager,ChoreographerCallback, andGameSdkDeviceInfoJni.The fix
Swappy checks the app class loader first, so shipping the two classes it asks for makes the DEX fallback unreachable. Frame pacing keeps working — this is not a workaround that disables it.
ChoreographerCallback.java/SwappyDisplayManager.java— vendored from AGDK (Apache-2.0), byte-identical to upstream below the added header comments, signatures cross-checked against the DEX decompiled out of Godot's own.so.GameSdkDeviceInfoJniis referenced only inside that DEX and never by native code, so two classes is the complete set.proguard-rules.pro— the build already referenced this file but it did not exist. R8 cannot see JNIRegisterNativesbindings, so keep rules prevent a futureisMinifyEnabled = truefrom silently reintroducing the bug.SwappyClassLoadingTest.kt— asserts the exact property Swappy branches on: the slash-separated names it passes resolve through the app class loader, with the bound members intact.Verification
Run on an emulator (SDK 36, arm64), reading Swappy's own log:
I SwappyDisplayManager: Using internal com/google/androidgamesdk/SwappyDisplayManager class from dex bytes.Starting looper threadThat line is the
ALOGIinside theInMemoryDexClassLoaderbranch itself, so this is direct observation of the crashing path being switched off.The instrumented test passes 2/2 with the classes and fails 2/2 without them, with
ClassNotFoundException: Didn't find class "com/google/androidgamesdk/SwappyDisplayManager"— verbatim the exceptiongamesdk::loadClasscatches to enter the fallback.Only
SwappyDisplayManageris loaded on modern Android, as the source predicts (the NDK choreographer wins on SDK >= 30);ChoreographerCallbackis included for the pre-30 devicesminSdk 26still allows.Not verified: I confirmed the fallback no longer fires on stock Android, but had no GrapheneOS device on hand to watch the
memory_DCLdenial not happen.Upstream notes
Two AGDK bugs worth reporting separately: the missing
ExceptionCheckbetweenNewObjectandCallObjectMethodingamesdk::loadClass(turns a recoverable denial into an abort), and theusesMinSdkOrLater/useSwappyDisplayManagermismatch that constructs a display manager Swappy itself declares unnecessary on SDK >= 31.Testing notes
./gradlew :app:connectedDebugAndroidTest -PskipGodot -Pandroid.testInstrumentationRunnerArguments.class=com.openbubbles.openpigeon.godot.SwappyClassLoadingTest🤖 Generated with Claude Code