diff --git a/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapper.java b/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapper.java new file mode 100644 index 00000000..bc135948 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapper.java @@ -0,0 +1,207 @@ + +package app.revanced.integrations.discord.plugin; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.content.res.Resources; +import android.graphics.Color; +import android.os.Build; +import app.revanced.integrations.shared.react.BaseRemoteReactPreloadScriptBootstrapper; +import com.facebook.react.bridge.CatalystInstanceImpl; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.lang.ref.WeakReference; +import java.util.HashMap; +import java.util.Objects; + + +public class BunnyBootstrapper extends BaseRemoteReactPreloadScriptBootstrapper { + private WeakReference context; + + private JSONObject theme; + private final HashMap RESOURCE_COLORS = new HashMap<>(); + private final HashMap COMPONENT_COLORS = new HashMap<>(); + + + @Override + protected void initialize(Context context) { + this.context = new WeakReference<>(context); + + download( + "https://raw.githubusercontent.com/pyoncord/detta-builds/main/bunny.js", + getWorkingDirectoryFile("bunny.bundle"), + 1024 + ); + + readThemeFile(); + } + + @Override + public void loadPreloadScripts(CatalystInstanceImpl instance) { + var config = new JSONObject(); + try { + config.put("loaderName", "ReVanced"); + config.put("loaderVersion", "1.0.0"); + config.put("hasThemeSupport", true); + buildThemeConfig(config); + buildSysColorsConfig(config); + } catch (Exception e) { + throw new RuntimeException(e); + } + + instance.setGlobalVariable("__PYON_LOADER__", config.toString()); + super.loadPreloadScripts(instance); + } + + private void buildThemeConfig(JSONObject config) throws JSONException { + config.put("storedTheme", theme); + } + + private void buildSysColorsConfig(JSONObject config) throws JSONException { + boolean isSystemColorsSupported = Build.VERSION.SDK_INT >= Build.VERSION_CODES.S; + + config.put("isSysColorsSupported", isSystemColorsSupported); + + if (isSystemColorsSupported) { + var context = this.context.get(); + var resources = context.getResources(); + var packageName = context.getPackageName(); + + String[] accents = {"accent1", "accent2", "accent3", "neutral1", "neutral2"}; + int[] shades = {0, 10, 50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 1000}; + + var colors = new JSONObject() {{ + for (String accent : accents) { + var accentColors = new JSONArray() {{ + for (int shade : shades) { + @SuppressLint("DiscouragedApi") + var colorResourceId = resources.getIdentifier( + "system_" + accent + "_" + shade, + "color", + packageName + ); + var color = colorResourceId == 0 ? 0 : context.getColor(colorResourceId); + var hexColor = String.format("#%06X", (0xFFFFFF & color)); + + put(hexColor); + } + }}; + + put(accent, accentColors); + } + }}; + + config.put("sysColors", colors); + } + } + + public int hookColorDark(String themeKey, int originalColor) { + return getColor(themeKey, originalColor, true); + } + + public int hookColorLight(String themeKey, int originalColor) { + return getColor(themeKey, originalColor, false); + } + + public int hookRawColor(Object contextOrResource, int id, int originalColor) { + return readRawColor(contextOrResource, id, originalColor); + } + + private int getColor(String colorName, int originalColor, boolean isDark) { + waitUntilInitialized(); + + var colors = COMPONENT_COLORS.get(colorName); + if (colors == null) { + return originalColor; + } + + if (isDark) { + return colors[0]; + } + + // Only if there are two colors in the array we return the light color + if (colors.length == 2) { + return colors[1]; + } + + return originalColor; + } + + private void readThemeFile() { + var themeFile = getWorkingDirectoryFile("pyoncord/theme.json"); + var legacyThemeFile = getWorkingDirectoryFile("vendetta_theme.json"); + + if (legacyThemeFile.exists() && !legacyThemeFile.renameTo(themeFile)) { + throw new RuntimeException("Failed to rename theme file"); + } + + if (!themeFile.exists()) { + return; + } + + try { + theme = new JSONObject(read(themeFile, 256)); + } catch (JSONException e) { + throw new RuntimeException(e); + } + + readThemeColors(); + } + + private int hexStringToColorInt(String hexString) { + var parsed = Color.parseColor(hexString); + return (hexString.length() == 7) ? parsed : parsed & 0xFFFFFF | (parsed >>> 24); + } + + private void readThemeColors() { + try { + var data = theme.getJSONObject("data"); + + var jsonRawColors = data.getJSONObject("rawColors"); + var jsonSemanticColors = data.getJSONObject("semanticColors"); + + for (var colors = jsonRawColors.keys(); colors.hasNext(); ) { + var colorKey = colors.next(); + int color = hexStringToColorInt(jsonRawColors.getString(colorKey)); + RESOURCE_COLORS.put(colorKey.toLowerCase(), color); + } + + for (var colors = jsonSemanticColors.keys(); colors.hasNext(); ) { + var componentName = colors.next(); + var componentColors = jsonSemanticColors.getJSONArray(componentName); + + int[] value; + if (componentColors.length() == 1) { + value = new int[]{hexStringToColorInt(componentColors.getString(0))}; + } else { + value = new int[]{ + hexStringToColorInt(componentColors.getString(0)), + hexStringToColorInt(componentColors.getString(1)) + }; + } + + COMPONENT_COLORS.put(componentName, value); + } + } catch (JSONException e) { + throw new RuntimeException(e); + } + } + + public int readRawColor(Object contextOrResource, int id, int originalColor) { + waitUntilInitialized(); + + Resources resources; + if (contextOrResource instanceof Context) { + resources = ((Context) contextOrResource).getResources(); + } else { + resources = (Resources) contextOrResource; + } + + var name = resources.getResourceEntryName(id); + var color = RESOURCE_COLORS.get(name); + + return Objects.requireNonNullElse(color, originalColor); + } +} diff --git a/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapperPatch.java b/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapperPatch.java new file mode 100644 index 00000000..ca110196 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/discord/plugin/BunnyBootstrapperPatch.java @@ -0,0 +1,29 @@ +package app.revanced.integrations.discord.plugin; + +import android.app.Activity; +import com.facebook.react.bridge.CatalystInstanceImpl; + +@SuppressWarnings("unused") +public final class BunnyBootstrapperPatch { + private final static BunnyBootstrapper INSTANCE = new BunnyBootstrapper(); + + public static void hookOnCreate(Activity mainActivity) { + INSTANCE.hookOnCreate(mainActivity); + } + + public static void hookLoadScriptFromFile(CatalystInstanceImpl instance) { + INSTANCE.hookLoadScriptFromFile(instance); + } + + public static int hookColorDark(String themeKey, int originalColor) { + return INSTANCE.hookColorDark(themeKey, originalColor); + } + + public static int hookColorLight(String themeKey, int originalColor) { + return INSTANCE.hookColorLight(themeKey, originalColor); + } + + public static int hookRawColor(Object contextOrResource, int id, int originalColor) { + return INSTANCE.hookRawColor(contextOrResource, id, originalColor); + } +} diff --git a/app/src/main/java/app/revanced/integrations/shared/react/BaseReactPreloadScriptBootstrapper.java b/app/src/main/java/app/revanced/integrations/shared/react/BaseReactPreloadScriptBootstrapper.java new file mode 100644 index 00000000..9c4290e2 --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/react/BaseReactPreloadScriptBootstrapper.java @@ -0,0 +1,81 @@ +package app.revanced.integrations.shared.react; + +import android.app.Activity; +import android.content.Context; +import com.facebook.react.bridge.CatalystInstanceImpl; + +import java.io.*; + +public abstract class BaseReactPreloadScriptBootstrapper { + private Thread initializeThread; + private File workingDirectory; + + protected abstract void initialize(Context context); + + public final void hookOnCreate(Activity mainActivity) { + workingDirectory = mainActivity.getFilesDir(); + if (!workingDirectory.exists() && !workingDirectory.mkdirs()) { + throw new RuntimeException("Failed to create working directory"); + } + + initializeThread = new Thread(() -> initialize(mainActivity)); + initializeThread.start(); + } + + public final void hookLoadScriptFromFile(CatalystInstanceImpl instance) { + waitUntilInitialized(); + loadPreloadScripts(instance); + } + + protected void loadPreloadScripts(CatalystInstanceImpl instance) { + final var preloadScripts = workingDirectory.listFiles(pathname -> + pathname.isFile() && pathname.getName().endsWith(".bundle")); + assert preloadScripts != null; + + for (final var preloadScript : preloadScripts) { + final var path = preloadScript.getAbsolutePath(); + instance.loadPreloadScriptFromFile(path, path, false); + } + } + + protected void waitUntilInitialized() { + try { + initializeThread.join(); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + } + + protected final File getWorkingDirectoryFile(String name) { + return new File(workingDirectory, name); + } + + protected final void write(InputStream inputStream, File file, int bufferSize) { + try (final var fileOutputStream = new FileOutputStream(file)) { + final var buffer = new byte[bufferSize]; + + int bytesRead; + while ((bytesRead = inputStream.read(buffer)) != -1) { + fileOutputStream.write(buffer, 0, bytesRead); + } + } catch (IOException e) { + throw new RuntimeException(e); + } + } + + protected final String read(File file, int bufferSize) { + try (final var fileInputStream = new FileInputStream(file)) { + final var buffer = new byte[bufferSize]; + final var stringBuilder = new StringBuilder(); + + int bytesRead; + while ((bytesRead = fileInputStream.read(buffer)) != -1) { + stringBuilder.append(new String(buffer, 0, bytesRead)); + } + + return stringBuilder.toString(); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/app/src/main/java/app/revanced/integrations/shared/react/BaseRemoteReactPreloadScriptBootstrapper.java b/app/src/main/java/app/revanced/integrations/shared/react/BaseRemoteReactPreloadScriptBootstrapper.java new file mode 100644 index 00000000..56f1d87c --- /dev/null +++ b/app/src/main/java/app/revanced/integrations/shared/react/BaseRemoteReactPreloadScriptBootstrapper.java @@ -0,0 +1,38 @@ +package app.revanced.integrations.shared.react; + +import java.io.*; +import java.net.HttpURLConnection; +import java.net.URL; + +@SuppressWarnings("unused") +public abstract class BaseRemoteReactPreloadScriptBootstrapper extends BaseReactPreloadScriptBootstrapper { + protected final void download(String url, File preloadScriptFile, int bufferSize) { + final var eTagFile = getWorkingDirectoryFile(preloadScriptFile.getName() + ".etag"); + + try { + HttpURLConnection connection = (HttpURLConnection) new URL(url).openConnection(); + if (eTagFile.exists() && preloadScriptFile.exists()) { + connection.setRequestProperty("If-None-Match", read(eTagFile, 256)); + } + connection.connect(); + + if (connection.getResponseCode() == 304) { + connection.disconnect(); + return; + } + + if (connection.getResponseCode() != 200) { + throw new RuntimeException("Failed to download the preload script: " + connection.getResponseCode()); + } + + final var eTagHeader = connection.getHeaderField("ETag"); + if (eTagHeader != null) { + write(new ByteArrayInputStream(eTagHeader.getBytes()), eTagFile, 256); + } + + write(connection.getInputStream(), preloadScriptFile, bufferSize); + } catch (IOException e) { + throw new RuntimeException(e); + } + } +} diff --git a/stub/src/main/java/com/facebook/react/bridge/CatalystInstanceImpl.java b/stub/src/main/java/com/facebook/react/bridge/CatalystInstanceImpl.java new file mode 100644 index 00000000..20cbb050 --- /dev/null +++ b/stub/src/main/java/com/facebook/react/bridge/CatalystInstanceImpl.java @@ -0,0 +1,16 @@ +package com.facebook.react.bridge; + +import android.content.res.AssetManager; + +public class CatalystInstanceImpl { + public native void setGlobalVariable(String propName, String jsonValue); + + public void loadScriptFromAssets(AssetManager assetManager, String assetURL, boolean loadSynchronously) { + } + + public void loadScriptFromFile(String fileName, String sourceURL, boolean loadSynchronously) { + } + + public void loadPreloadScriptFromFile(String fileName, String sourceURL, boolean loadSynchronously) { + } +}