// credits to janisslsm from his PR: https://github.com/vendetta-mod/VendettaXposed/pull/17 // hooks are modified function from RN codebase package io.github.revenge.xposed import android.content.res.AssetManager import android.os.Build import android.graphics.Typeface import android.graphics.Typeface.CustomFallbackBuilder import android.graphics.fonts.Font import android.graphics.fonts.FontFamily import android.util.Log import de.robv.android.xposed.XC_MethodReplacement import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage import kotlinx.serialization.Serializable import kotlinx.serialization.json.* import java.io.IOException import java.io.File import kotlinx.coroutines.* import io.ktor.client.* import io.ktor.client.call.* import io.ktor.client.request.* import io.ktor.client.engine.cio.* import io.ktor.client.statement.* import io.ktor.client.plugins.* import io.ktor.http.* @Serializable data class FontDefinition( val name: String? = null, val description: String? = null, val spec: Int? = null, val main: Map, ) class FontsModule: Module() { private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") private val FONTS_ASSET_PATH = "fonts/" private lateinit var fontsDir: File private lateinit var fontsDownloadsDir: File private var fontsAbsPath: String? = null override fun buildJson(builder: JsonObjectBuilder) { builder.apply { put("fontPatch", 2) } } override fun onInit(packageParam: XC_LoadPackage.LoadPackageParam) = with (packageParam) { XposedHelpers.findAndHookMethod("com.facebook.react.views.text.ReactFontManager", classLoader, "createAssetTypeface", String::class.java, Int::class.java, "android.content.res.AssetManager", object : XC_MethodReplacement() { override fun replaceHookedMethod(param: MethodHookParam): Typeface? { val fontFamilyName: String = param.args[0].toString() val style: Int = param.args[1] as Int val assetManager: AssetManager = param.args[2] as AssetManager return createAssetTypeface(fontFamilyName, style, assetManager) } }) val fontDefFile = File(appInfo.dataDir, "files/pyoncord/fonts.json") if (!fontDefFile.exists()) return@with val fontDef = try { val json = Json { ignoreUnknownKeys = true } json.decodeFromString(fontDefFile.readText()) } catch (_: Throwable) { return@with } fontsDownloadsDir = File(appInfo.dataDir, "files/pyoncord/downloads/fonts").apply { mkdirs() } fontsDir = File(fontsDownloadsDir, fontDef.name!!).apply { mkdirs() } fontsAbsPath = fontsDir.absolutePath + "/" fontsDir.listFiles()?.forEach { file -> val fileName = file.name if (!fileName.startsWith(".")) { val fontName = fileName.split('.')[0] if (fontDef.main.keys.none { it == fontName }) { Log.i("Revenge", "Deleting font file: $fileName") file.delete() } } } // These files should be downloaded by the JS side, but oh well CoroutineScope(Dispatchers.IO).launch { fontDef.main.keys.map { name -> async { val url = fontDef.main.getValue(name) try { Log.i("Revenge", "Downloading $name from $url") val file = File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}") if (file.exists()) return@async val client = HttpClient(CIO) { install(UserAgent) { agent = "RevengeXposed" } } val response: HttpResponse = client.get(url) if (response.status == HttpStatusCode.OK) { file.writeBytes(response.body()) } return@async } catch (e: Throwable) { Log.e("Revenge", "Failed to download fonts ($name from $url)", e) } } }.awaitAll() } return@with } private fun createAssetTypefaceWithFallbacks( fontFamilyNames: Array, style: Int, assetManager: AssetManager ): Typeface? { val fontFamilies: MutableList = ArrayList() if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { // Iterate over the list of fontFamilyNames, constructing new FontFamily objects // for use in the CustomFallbackBuilder below. for (fontFamilyName in fontFamilyNames) { try { for (fileExtension in FILE_EXTENSIONS) { val (customName, refName) = fontFamilyName.split(":") val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension") val font = Font.Builder(file).build() val family = FontFamily.Builder(font).build() fontFamilies.add(family) } } catch (e: Throwable) { // ignore } for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { for (fileExtension in FILE_EXTENSIONS) { val fileName = java.lang.StringBuilder() .append(fontRootPath) .append(fontFamilyName) .append(fileExtension) .toString() try { val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) else Font.Builder(assetManager, fileName) val font = builder.build() val family = FontFamily.Builder(font).build() fontFamilies.add(family) } catch (e: java.lang.RuntimeException) { // If the typeface asset does not exist, try another extension. continue } catch (e: IOException) { // If the font asset does not exist, try another extension. continue } } } } // If there's some problem constructing fonts, fall back to the default behavior. if (fontFamilies.size == 0) { return createAssetTypeface(fontFamilyNames[0], style, assetManager) } val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0]) for (i in 1 until fontFamilies.size) { fallbackBuilder.addCustomFallback(fontFamilies[i]) } return fallbackBuilder.build() } return null } private fun createAssetTypeface( fontFamilyName_: String, style: Int, assetManager: AssetManager ): Typeface? { // This logic attempts to safely check if the frontend code is attempting to use // fallback fonts, and if it is, to use the fallback typeface creation logic. var fontFamilyName: String = fontFamilyName_ val fontFamilyNames = fontFamilyName.split(",".toRegex()) .dropLastWhile { it.isEmpty() } .toTypedArray() for (i in fontFamilyNames.indices) { fontFamilyNames[i] = fontFamilyNames[i].trim() } // If there are multiple font family names: // For newer versions of Android, construct a Typeface with fallbacks // For older versions of Android, ignore all the fallbacks and just use the first font family if (fontFamilyNames.size > 1) { fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager) } else { fontFamilyNames[0] } } val extension = EXTENSIONS[style] try { for (fileExtension in FILE_EXTENSIONS) { val (customName, refName) = fontFamilyName.split(":") val file = File(fontsDownloadsDir, "$customName/$refName.$fileExtension") if (!file.exists()) throw Exception() return Typeface.createFromFile(file.absolutePath) } } catch (e: Throwable) { // ignore } // Lastly, after all those checks above, this is the original RN logic for // getting the typeface. for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { for (fileExtension in FILE_EXTENSIONS) { val fileName = java.lang.StringBuilder() .append(fontRootPath) .append(fontFamilyName) .append(extension) .append(fileExtension) .toString() return try { if (fileName[0] == '/') Typeface.createFromFile(fileName) else Typeface.createFromAsset(assetManager, fileName) } catch (e: java.lang.RuntimeException) { // If the typeface asset does not exist, try another extension. continue } } } return Typeface.create(fontFamilyName, style) } }