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); +}