Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions THIRD-PARTY-NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/`
<https://android.googlesource.com/platform/frameworks/opt/gamesdk/+/refs/heads/main/games-frame-pacing/extras/>

`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
Expand Down
13 changes: 13 additions & 0 deletions app/proguard-rules.pro
Original file line number Diff line number Diff line change
@@ -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 { *; }
Original file line number Diff line number Diff line change
@@ -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)
)
}
}
Original file line number Diff line number Diff line change
@@ -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);

}
Loading