diff --git a/fusionApp/build.gradle.kts b/fusionApp/build.gradle.kts index 89f724e..5c2a5ab 100644 --- a/fusionApp/build.gradle.kts +++ b/fusionApp/build.gradle.kts @@ -15,6 +15,8 @@ dependencies { implementation("top.canyie.pine:core:0.3.0") implementation("io.github.hexhacking:xdl:2.3.0") implementation("androidx.annotation:annotation-jvm:1.9.1") + // fuck you too vr android yeah thanks + implementation("androidx.appcompat:appcompat:1.7.1") } android { diff --git a/fusionApp/src/main/AndroidManifest.xml b/fusionApp/src/main/AndroidManifest.xml index 9a12b62..9b73762 100644 --- a/fusionApp/src/main/AndroidManifest.xml +++ b/fusionApp/src/main/AndroidManifest.xml @@ -1,29 +1,42 @@ - - - - - - - + + + + + + + + + + + - + + - + @@ -31,36 +44,86 @@ - + + + + - + + + + + - + - + - + + + + + + android:name="dev.allofus.fusioncore.BootstrapActivity" + android:theme="@style/AppTheme" + android:launchMode="singleTask" + android:process=":vr" + android:exported="true"> + + + + + + android:name="dev.allofus.fusioncore.SettingsActivity" + android:theme="@style/UnityThemeSelector" + android:exported="false" /> \ No newline at end of file diff --git a/fusionApp/src/main/java/dev/allofus/fusioncore/AppCompatBypassHooks.java b/fusionApp/src/main/java/dev/allofus/fusioncore/AppCompatBypassHooks.java new file mode 100644 index 0000000..c86c768 --- /dev/null +++ b/fusionApp/src/main/java/dev/allofus/fusioncore/AppCompatBypassHooks.java @@ -0,0 +1,183 @@ +package dev.allofus.fusioncore; + +import android.app.Activity; +import android.os.Bundle; +import android.util.Log; +import android.view.LayoutInflater; +import android.view.View; +import android.view.ViewGroup; +import android.view.Window; + +import java.lang.reflect.Field; +import java.lang.reflect.Method; +import java.lang.reflect.Modifier; +import java.util.Arrays; +import java.util.HashSet; +import java.util.Set; + +import top.canyie.pine.Pine; +import top.canyie.pine.callback.MethodHook; + +public final class AppCompatBypassHooks { + private static final String TAG = "AppCompatBypassHooks"; + + private static final Set TARGET_METHODS = new HashSet<>(Arrays.asList( + "setContentView", + "findViewById", + "addContentView", + "getMenuInflater", + "onPostCreate", // triggers ensureSubDecor → theme assertion + "onPostResume", // delegate.onPostResume → applyDayNight → ensureSubDecor + "onConfigurationChanged", // delegate routes this too + "onStop", + "onDestroy", + "onTitleChanged", + "onMenuOpened", + "onPanelClosed" + )); + + // Activity#mCalled is set by Activity.onPostCreate's super impl. Instrumentation + // checks it after dispatch and throws SuperNotCalledException if false. Since we + // suppress the entire override (Method.invoke on the super method does virtual + // dispatch and would recurse back into us), set the field directly. + private static Field sActivityMCalled; + + static { + try { + sActivityMCalled = Activity.class.getDeclaredField("mCalled"); + sActivityMCalled.setAccessible(true); + } catch (NoSuchFieldException e) { + Log.e(TAG, "Could not cache Activity#mCalled", e); + } + } + + private AppCompatBypassHooks() {} + + public static void installHooks(ClassLoader gameClassLoader, String launcherClassName) { + Class launcher; + try { + launcher = gameClassLoader.loadClass(launcherClassName); + } catch (ClassNotFoundException e) { + Log.w(TAG, "Launcher class not found: " + launcherClassName, e); + return; + } + + Class current = launcher; + while (current != null && current != Activity.class) { + for (Method method : current.getDeclaredMethods()) { + if (!TARGET_METHODS.contains(method.getName())) continue; + int mods = method.getModifiers(); + // Pine can't hook abstract or native methods. ComponentActivity declares + // setContentView(int) as abstract, which would otherwise abort the loop. + if (Modifier.isAbstract(mods) || Modifier.isNative(mods)) continue; + try { + hook(method, current); + } catch (Throwable t) { + Log.w(TAG, "Skipping unhookable method " + + current.getName() + "#" + method.getName(), t); + } + } + current = current.getSuperclass(); + } + } + + private static void hook(Method method, Class declaringClass) { + method.setAccessible(true); + Pine.hook(method, new MethodHook() { + @Override + public void beforeCall(Pine.CallFrame callFrame) { + if (!(callFrame.thisObject instanceof Activity)) return; + + Activity activity = (Activity) callFrame.thisObject; + Window window = activity.getWindow(); + Object[] args = callFrame.args == null ? new Object[0] : callFrame.args; + String name = method.getName(); + + try { + switch (name) { + case "setContentView": + if (args.length == 1 && args[0] instanceof View) { + window.setContentView((View) args[0]); + } else if (args.length == 1 && args[0] instanceof Integer) { + window.setContentView((Integer) args[0]); + } else if (args.length == 2 + && args[0] instanceof View + && args[1] instanceof ViewGroup.LayoutParams) { + window.setContentView((View) args[0], (ViewGroup.LayoutParams) args[1]); + } else { + return; + } + callFrame.setResult(null); + break; + + case "findViewById": + if (args.length == 1 && args[0] instanceof Integer) { + callFrame.setResult(window.findViewById((Integer) args[0])); + } + break; + + case "addContentView": + if (args.length == 2 + && args[0] instanceof View + && args[1] instanceof ViewGroup.LayoutParams) { + window.addContentView((View) args[0], (ViewGroup.LayoutParams) args[1]); + callFrame.setResult(null); + } + break; + + case "getMenuInflater": + callFrame.setResult(LayoutInflater.from(activity)); + break; + + case "onPostCreate": + // Suppress the AppCompat override entirely. Reflection-invoking + // Activity#onPostCreate would virtually dispatch back into this + // same hook (infinite recursion), so instead satisfy the + // Instrumentation contract by flipping mCalled directly. + if (sActivityMCalled != null) { + sActivityMCalled.setBoolean(activity, true); + } + callFrame.setResult(null); + break; + + case "onPostResume": + // Activity.onPostResume's super impl calls window.makeActive() + // (required so input dispatching works) and sets mCalled = true. + // Replicate both, then suppress the AppCompat override. + if (window != null) { + window.makeActive(); + } + if (sActivityMCalled != null) { + sActivityMCalled.setBoolean(activity, true); + } + callFrame.setResult(null); + break; + + // Lifecycle hooks that the framework checks mCalled on after dispatch. + // The AppCompat override calls delegate.onX() which trips applyDayNight + // → ensureSubDecor → theme assertion. Suppress the override entirely + // and flip mCalled so Instrumentation doesn't throw SuperNotCalled. + case "onStop": + case "onDestroy": + case "onConfigurationChanged": + if (sActivityMCalled != null) { + sActivityMCalled.setBoolean(activity, true); + } + callFrame.setResult(null); + break; + + // No mCalled check on these - just suppress the AppCompat path. + case "onTitleChanged": + case "onMenuOpened": + case "onPanelClosed": + callFrame.setResult(null); + break; + } + } catch (Throwable t) { + Log.e(TAG, "Bypass for " + name + " failed", t); + } + } + }); + Log.i(TAG, "Hooked " + declaringClass.getName() + "#" + method.getName()); + } +} \ No newline at end of file diff --git a/fusionApp/src/main/java/dev/allofus/fusioncore/BootstrapActivity.java b/fusionApp/src/main/java/dev/allofus/fusioncore/BootstrapActivity.java index cd990e7..59c45fd 100644 --- a/fusionApp/src/main/java/dev/allofus/fusioncore/BootstrapActivity.java +++ b/fusionApp/src/main/java/dev/allofus/fusioncore/BootstrapActivity.java @@ -100,12 +100,18 @@ private void runBootstrapFlow(String targetPackage) { try { ClassLoaderHooks.installHooks(gameContext.getClassLoader()); PackageManagerHooks.installHooks(getPackageManager()); - UnityPlayerHooks.installHooks(gameContext); + UnityPlayerHooks.installHooks(gameContext, new File(preparedState.config.unityDataDirectory)); } catch (Exception e) { Log.e(TAG, "Failed to install base hooks", e); } final String launcherClassName = launcher.getClassName(); + try { + AppCompatBypassHooks.installHooks(gameContext.getClassLoader(), launcherClassName); + } catch (Exception e) { + Log.e(TAG, "Failed to install AppCompat bypass hooks", e); + } + if (!installLauncherOnCreateHook(gameContext.getClassLoader(), launcherClassName, (launcherActivity, bundle) -> initializeFusion(launcherActivity, targetPackage))) { failAndFinish("Failed to install launcher hook! See log for details.", null); diff --git a/fusionApp/src/main/java/dev/allofus/fusioncore/SelectorActivity.java b/fusionApp/src/main/java/dev/allofus/fusioncore/SelectorActivity.java index 8a0edcd..fd42603 100644 --- a/fusionApp/src/main/java/dev/allofus/fusioncore/SelectorActivity.java +++ b/fusionApp/src/main/java/dev/allofus/fusioncore/SelectorActivity.java @@ -40,7 +40,8 @@ public class SelectorActivity extends Activity { "com.antiherostudios.misfitz", "com.Radeon.RecRoom", "com.StefMorojna.SpaceflightSimulator", - "com.DanVogt.DATAWING" + "com.DanVogt.DATAWING", + "com.schellgames.amongusvr" }; private String pendingLaunchPackage; diff --git a/fusionApp/src/main/java/dev/allofus/fusioncore/UnityPlayerHooks.java b/fusionApp/src/main/java/dev/allofus/fusioncore/UnityPlayerHooks.java index aee85c6..8e94df7 100644 --- a/fusionApp/src/main/java/dev/allofus/fusioncore/UnityPlayerHooks.java +++ b/fusionApp/src/main/java/dev/allofus/fusioncore/UnityPlayerHooks.java @@ -2,6 +2,7 @@ import android.app.Activity; import android.content.Context; +import android.content.res.AssetManager; import android.graphics.Color; import android.os.Looper; import android.util.Log; @@ -13,8 +14,10 @@ import android.widget.ProgressBar; import android.widget.TextView; +import java.io.File; import java.lang.reflect.Constructor; import java.lang.reflect.Field; +import java.lang.reflect.Method; import java.util.ArrayList; import java.util.concurrent.CountDownLatch; @@ -32,7 +35,7 @@ public class UnityPlayerHooks { }; // this is used to inject CustomContextWrapper into the game activity - public static void installHooks(Context gameContext) { + public static void installHooks(Context gameContext, File unityDataDir) { var classLoader = gameContext.getClassLoader(); if (classLoader == null) { throw new IllegalStateException("ClassLoader is null"); @@ -72,6 +75,12 @@ public static void installHooks(Context gameContext) { for (Constructor constructor : constructors) { Log.i(TAG, "Hooking constructor: " + constructor); + // If the constructor's first parameter is strictly Activity (not just Context), + // Pine's reflective backup invoke rejects a ContextWrapper substitute, so we can't + // swap in a CustomContextWrapper (UnityPlayerForGameActivity's ctor takes Activity + // strictly). In that case leave the arg untouched and redirect the game's assets + // directly instead. + final boolean canWrapArg = !Activity.class.isAssignableFrom(constructor.getParameterTypes()[0]); Pine.hook(constructor, new MethodHook() { Activity activity = null; View loadingOverlay; @@ -83,12 +92,42 @@ public void beforeCall(Pine.CallFrame callFrame) { Log.w(TAG, "First argument is not a Activity, skipping hook"); return; } - // In UnityPlayerHooks beforeCall: - Log.i("UnityPlayerHooks", "Constructor firing, context class: " + Log.i(TAG, "Constructor firing, context class: " + callFrame.args[0].getClass().getName()); activity = (Activity) callFrame.args[0]; loadingOverlay = showLoadingOverlay(activity, "Injecting Fusion hooks..."); - callFrame.args[0] = new CustomContextWrapper(gameContext, activity, activity); + if (canWrapArg) { + callFrame.args[0] = new CustomContextWrapper(gameContext, activity, activity); + } else { + // GameActivity path: can't replace the Activity arg, so serve the + // game's assets (and the on-disk il2cpp metadata) via hooks instead. + final AssetManager gameAssets = gameContext.getAssets(); + final File metadataFile = new File(unityDataDir, "Managed/Metadata/global-metadata.dat"); + + Method openMethod = AssetManager.class.getMethod("open", String.class); + Pine.hook(openMethod, new MethodHook() { + @Override + public void beforeCall(Pine.CallFrame frame) { + String path = (String) frame.args[0]; + if (path != null && path.endsWith("global-metadata.dat")) { + try { + Log.i(TAG, "Redirecting AssetManager.open(" + path + ") to disk copy"); + frame.setResult(new java.io.FileInputStream(metadataFile)); + } catch (Exception e) { + Log.e(TAG, "Failed to open on-disk metadata", e); + } + } + } + }); + + Method getAssetsMethod = android.view.ContextThemeWrapper.class.getMethod("getAssets"); + Pine.hook(getAssetsMethod, new MethodHook() { + @Override + public void beforeCall(Pine.CallFrame frame) { + frame.setResult(gameAssets); + } + }); + } } catch (Exception e) { Log.i(TAG, "Failed to wrap context!", e); } diff --git a/fusionApp/src/main/res/values-v21/styles.xml b/fusionApp/src/main/res/values-v21/styles.xml index 50c5541..03def0f 100644 --- a/fusionApp/src/main/res/values-v21/styles.xml +++ b/fusionApp/src/main/res/values-v21/styles.xml @@ -1,6 +1,7 @@ - - - + + + + - + + + + \ No newline at end of file