Skip to content
Draft
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
3 changes: 1 addition & 2 deletions app/src/main/java/app/unbound/android/Constants.kt
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ data class Theme (
data class ThemeJSON (
@SerializedName("raw") val raw: JsonElement?,
@SerializedName("semantic") val semantic: JsonElement?,
@SerializedName("type") val type: JsonElement?,
@SerializedName("background") val background: JsonElement?
)

Expand All @@ -40,8 +41,6 @@ class Constants {

const val CLASS = "com.facebook.react.bridge.CatalystInstanceImpl"
const val ACTIVITY_CLASS = "android.app.Instrumentation"
const val LIGHT_THEME = "com.discord.theme.LightTheme"
const val DARK_THEME = "com.discord.theme.DarkTheme"

const val FILE_LOAD = "jniLoadScriptFromFile"
const val ASSET_LOAD = "jniLoadScriptFromAssets"
Expand Down
223 changes: 162 additions & 61 deletions app/src/main/java/app/unbound/android/Themes.kt
Original file line number Diff line number Diff line change
@@ -1,12 +1,19 @@
package app.unbound.android

import android.content.Context
import android.content.res.Resources
import android.util.Log
import com.google.gson.JsonElement
import com.google.gson.JsonObject
import de.robv.android.xposed.XC_MethodHook
import de.robv.android.xposed.XposedBridge

class Themes : Manager() {
companion object {
val raw = mutableMapOf<String, Int>()
val semanticHooks = mutableListOf<XC_MethodHook.Unhook>()
val stockThemes = arrayOf("dark", "darker", "midnight", "amoled", "light")
var themeType: String? = null
}

init {
Expand All @@ -16,7 +23,63 @@ class Themes : Manager() {
val isInRecovery = Unbound.settings.get("unbound", "recovery", false) as Boolean

if (isEnabled && !isInRecovery) {
this.apply()
val updateTheme = Unbound.info.classLoader.loadClass("com.discord.theme.ThemeModule").getDeclaredMethod("updateTheme", String::class.java)
XposedBridge.hookMethod(updateTheme, object : XC_MethodHook() { // Runs on startup
override fun beforeHookedMethod(param: MethodHookParam) {
Log.d("Unbound", "[Themes] Hooked updateTheme! : ${param.args[0]}")

if (param.args[0] !in stockThemes) {
//doesnt function if you enable directly after adding | should impl https://developer.android.com/reference/android/os/FileObserver to update addons
val theme = getTheme(param.args[0] as String)
if (theme == null) { param.result = null; return }
themeType = theme.bundle.type?.asString

Log.d("Unbound", "[Themes] ${param.args[0]} is $themeType")

rawConstructor(theme.bundle.raw)
hookSemantic(theme.bundle.semantic)


/* Reimplement updater branch:
android.app.Activity r4 = r3.getCurrentActivity()
if (r4 == 0) goto L58 // return
com.discord.theme.a r0 = new com.discord.theme.a
r0.<init>()
r4.runOnUiThread(r0)
*/
// Not sure if this is necessary but stock updateTheme does and we are preventing its execution
// a.run() does eventually call some updateUI functions so i assume its useful for live updating
val a = Unbound.info.classLoader.loadClass("com.discord.theme.a")
val constructor = a.getDeclaredConstructor(param.thisObject.javaClass)
val runnable = constructor.newInstance(param.thisObject) as Runnable

Activities.current.get()?.runOnUiThread(runnable)

param.result = null // Don't run stock updateTheme(), will crash if it gets a custom theme id
return
}

raw.clear() // Remove custom colouring
semanticHooks.forEach { it.unhook() }
themeType = null
}
})

val themeManager = Unbound.info.classLoader.loadClass("com.discord.theme.ThemeManager")
fun hookIsThemeMethods(methodName: String, expectedType: String) {
XposedBridge.hookMethod(themeManager.getDeclaredMethod(methodName), object : XC_MethodHook() {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you a predicate for error prevention here incase getDeclaredMethod doesn't return anything

override fun beforeHookedMethod(param: MethodHookParam) {
val buh = themeType == expectedType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Name this variable properly

Log.d("Unbound", "$methodName called: $themeType, $buh")
if (themeType != null) param.result = buh
}
})
}
hookIsThemeMethods("isThemeLight", "light")
hookIsThemeMethods("isThemeDark", "dark")

hookRaw()
apply()
}
}

Expand All @@ -36,81 +99,119 @@ class Themes : Manager() {
return Unbound.gson.fromJson(bundle, ThemeJSON::class.java)
}

private fun getApplied(): Theme? {
val key = Unbound.settings.get("theme-states", "applied", null)
if (key == "" || key !is String) return null

private fun getTheme(key: String): Theme? {
val theme = this.addons.find { t -> (t as Theme).manifest.id == key }
if (theme != null) {
return theme as Theme
}

return null
Log.d("Unbound", "[Themes] Applied theme: $theme")
return theme as? Theme
}

private fun apply() {
val addon = this.getApplied() ?: return

if (addon.bundle.raw != null) {
val colors = addon.bundle.raw.asJsonObject.entrySet()
val key = Unbound.settings.get("themes", "applied", null)
if (key == "" || key !is String) return

colors.forEach { (key, value) ->
val color = Utilities.parseColor(value.asString) ?: return@forEach
getTheme(key)?.let { addon ->
rawConstructor(addon.bundle.raw)
}
}

private fun rawConstructor(addon: JsonElement?) {
raw.clear()
addon?.asJsonObject?.entrySet()?.forEach { (key, value) ->
val color = Utilities.parseColor(value.asString)
if (color != null) {
raw[key.lowercase()] = color
} else {
Log.w("Unbound", "[Themes] Failed to parse raw color: $key")
}
}
}
private fun hookRaw() {
val colorUtils = Unbound.info.classLoader.loadClass("com.discord.theme.utils.ColorUtilsKt")
val getColorCompatLegacy = colorUtils.getDeclaredMethod("getColorCompat", Resources::class.java, Int::class.javaPrimitiveType, Resources.Theme::class.java)
val getColorCompat = colorUtils.getDeclaredMethod("getColorCompat", Context::class.java, Int::class.javaPrimitiveType)

if (addon.bundle.semantic != null) {
val colors = addon.bundle.semantic.asJsonObject.entrySet()
val loader = Unbound.info.classLoader

val dark = loader.loadClass(Constants.DARK_THEME)
val light = loader.loadClass(Constants.LIGHT_THEME)

colors.forEach { (key, value) ->
// Keyboard theming is not yet supported on android
if (key == "KEYBOARD") return@forEach

val color = value.asJsonArray

val segments = key.split("_")
val getter = segments.joinToString("") { it.lowercase().replaceFirstChar { it.uppercase() } }
val method = "get$getter"

color.forEachIndexed { index, v ->
try {
val string = color.get(index)
val parsed = Utilities.parseColor(string.asString) ?: return@forEachIndexed

when (index) {
0 -> this.swizzle(
dark,
method,
parsed
)

1 -> this.swizzle(
light,
method,
parsed
)
}
} catch (e: Exception) {
Log.wtf("Unbound", "Failed to apply theme color $key, $v")
}
}
val patch = object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
val resources = (param.args[0] as? Context)?.resources ?: (param.args[0] as Resources)
val name = resources.getResourceEntryName(param.args[1] as Int)
// Log.d("Unbound", "[Themes] Swizzling raw: $name")

// raw[name]?.let {
if (raw[name] != null) {
Log.d("Unbound", "[Themes] Swizzled raw: $name, ${raw[name]}")
param.result = raw[name] //it
} //else {
// Log.d("Unbound", "[Themes] Swizzled unset raw: $name, ${raw[name]}, #fc03f8")
// param.result = Utilities.parseColor("#fc03f8")
// }
}
}

XposedBridge.hookMethod(getColorCompat, patch)
XposedBridge.hookMethod(getColorCompatLegacy, patch)
}

private fun swizzle(theme: Class<*>, method: String, value: Int) {
val implementation = theme.getDeclaredMethod(method)
private fun hookSemantic(addon: JsonElement?) {
semanticHooks.forEach { it.unhook() } // Remove all previous hooks to let new themes do their thing

XposedBridge.hookMethod(implementation, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = value
val getTheme = try {
Unbound.info.classLoader
.loadClass("com.discord.theme.ThemeManagerKt")
.getDeclaredMethod("getTheme")
} catch (e: Exception) {
Log.e("Unbound", "[Themes] failed to retrieve getTheme(): $e")
return
}

// val unwantedMethods = arrayOf("getClass", "getColor", "getColorRes")
// for (method in themeClass.methods) {
// Log.d("Unbound", "[Themes] Method: ${method.name}, Parameters: ${method.parameterTypes.joinToString()}")
// if (method.name.startsWith("get") && method.name !in unwantedMethods) {
// semanticSwizzle(themeClass, method.name, Utilities.parseColor("#f0f"), "key")
// }
// }

addon?.asJsonObject?.entrySet()?.forEach { (key, json) ->
val obj = json.asJsonObject

val themeClass = getTheme.invoke(null)::class.java

val segments = key.split("_")
val getterMethod = "get" + segments.joinToString("") { it.lowercase().replaceFirstChar(Char::uppercase) }


if (obj.get("type").asString == "color") {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't chain accessors on methods that might return null.

val colorValue = obj.get("value").asString
val colorOpacity = obj.get("opacity")?.asFloat

val color = Utilities.parseColor(colorValue, colorOpacity)

semanticSwizzle(themeClass, getterMethod, color, key)

} else if (obj.get("type").asString == "raw") {
// Unimplemented
// val rawKey = obj.get("value").asString
// val colorOpacity = obj.get("opacity")?.asFloat
return
}
})
}
}
private fun semanticSwizzle(theme: Class<*>, method: String, value: Int?, key: String) {
try {
if (value != null) {
Log.d("Unbound", "[Themes] Applying semantic $key")
val implementation = theme.getDeclaredMethod(method)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

An error prevention predicate here aswell

val hook = XposedBridge.hookMethod(implementation, object : XC_MethodHook() {
override fun beforeHookedMethod(param: MethodHookParam) {
param.result = value
}
})
semanticHooks.add(hook)
} else { throw IllegalArgumentException("value parsed to null.") }
} catch (e: NoSuchMethodException) { // Common as most semantic strings aren't native
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Instead of catching it's better to prevent. Throwing errors is usually very slow due to stack traces. The predicate above should prevent this from throwing in the first place.

Log.w("Unbound", "[Themes] $key is not available on native")
} catch (e: Exception) {
Log.e("Unbound", "[Themes] Error applying theme color $key", e)
}
}
}
8 changes: 4 additions & 4 deletions app/src/main/java/app/unbound/android/Utilities.kt
Original file line number Diff line number Diff line change
Expand Up @@ -58,10 +58,10 @@ class Utilities(param: XC_LoadPackage.LoadPackageParam) {
}
}

fun parseColor(color: String): Int? {
val parsed = Color.parseOrNull(color) ?: return null

return parsed.toSRGB().toRGBInt().argb.toInt()
fun parseColor(color: String, opacity: Float? = null): Int? {
return Color.parseOrNull(color)?.toSRGB()?.let { srgb ->
srgb.copy(alpha = (opacity ?: srgb.alpha).coerceIn(0f, 1f)).toRGBInt().argb.toInt()
}
}
}
}
Expand Down