From 82214b6ed0085c86e520c96323fefbf60500c6c0 Mon Sep 17 00:00:00 2001 From: OTKUSteyler Date: Fri, 11 Sep 2026 21:48:46 +0200 Subject: [PATCH] Update FontsModule.kt --- .../xposed/modules/appearance/FontsModule.kt | 222 ++++++++++-------- 1 file changed, 119 insertions(+), 103 deletions(-) diff --git a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt index 5042b78..dbf90b2 100644 --- a/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt +++ b/app/src/main/kotlin/ShiggyXposed/xposed/xposed/modules/appearance/FontsModule.kt @@ -1,3 +1,5 @@ +@file:Suppress("DEPRECATION") + package GoonXposed.xposed.modules.appearance import android.content.res.AssetManager @@ -6,6 +8,8 @@ import android.graphics.Typeface.CustomFallbackBuilder import android.graphics.fonts.Font import android.graphics.fonts.FontFamily import android.os.Build +import de.robv.android.xposed.XC_MethodReplacement +import de.robv.android.xposed.XposedHelpers import de.robv.android.xposed.callbacks.XC_LoadPackage import GoonXposed.xposed.Constants import GoonXposed.xposed.Module @@ -13,23 +17,17 @@ import GoonXposed.xposed.Utils.Companion.JSON import GoonXposed.xposed.Utils.Log import GoonXposed.xposed.asDir import GoonXposed.xposed.asFile -import GoonXposed.xposed.hook -import GoonXposed.xposed.method -import GoonXposed.xposed.safeLoadClass -import io.ktor.client.HttpClient -import io.ktor.client.call.body -import io.ktor.client.engine.cio.CIO -import io.ktor.client.plugins.HttpRedirect -import io.ktor.client.plugins.UserAgent -import io.ktor.client.request.get -import io.ktor.client.statement.HttpResponse -import io.ktor.http.HttpStatusCode -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.async -import kotlinx.coroutines.awaitAll -import kotlinx.coroutines.launch +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.http.* +import kotlinx.coroutines.* import kotlinx.serialization.Serializable +import kotlinx.serialization.json.JsonObjectBuilder +import kotlinx.serialization.json.put import java.io.File import java.io.IOException @@ -41,10 +39,19 @@ data class FontDefinition( val main: Map, ) -/** - * Custom font loading + ReactFontManager hijack. - */ object FontsModule : Module() { + private val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + private val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + private const val FONTS_ASSET_PATH = "fonts/" + + private lateinit var fontsDir: File + private var fontsDownloadsDir: File? = null + private var fontsAbsPath: String? = null + + override fun buildPayload(builder: JsonObjectBuilder) { + builder.put("fontPatch", 2) + } + override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { // ReactFontManager hijack runs regardless of fonts.json presence; it falls back to the // default Typeface chain if no custom font file is found. @@ -52,43 +59,47 @@ object FontsModule : Module() { "com.facebook.react.common.assets.ReactFontManager\$Companion", "com.facebook.react.views.text.ReactFontManager\$Companion", ).forEach { clsName -> - classLoader.safeLoadClass(clsName)?.let { cls -> + XposedHelpers.findClassIfExists(clsName, classLoader)?.let { cls -> runCatching { - cls.method("createAssetTypeface", String::class.java, Int::class.java, AssetManager::class.java).hook { - before { - val fontFamilyName: String = args[0].toString() - val style: Int = args[1] as Int - val assetManager: AssetManager = args[2] as AssetManager - result = FontsState.createAssetTypeface(fontFamilyName, style, assetManager) - } - } + XposedHelpers.findAndHookMethod( + cls, + "createAssetTypeface", + String::class.java, + Int::class.java, + AssetManager::class.java, + object : XC_MethodReplacement() { + override fun replaceHookedMethod(param: MethodHookParam): Any? { + 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) + } + }, + ) }.onFailure { e -> Log.e("Failed to hook ReactFontManager: ${e.message}") } } } - } - override fun onContext(context: android.content.Context) { - val dataDir = context.dataDir.absolutePath - val fontDefFile = File(dataDir, "${Constants.FILES_DIR}/fonts.json").asFile() - if (!fontDefFile.exists()) return + val fontDefFile = File(appInfo.dataDir, "${Constants.FILES_DIR}/fonts.json") + if (!fontDefFile.exists()) return@with val fontDef = try { JSON.decodeFromString(fontDefFile.readText()) } catch (e: Throwable) { Log.w("fonts.json malformed: ${e.message}") - return + return@with } - val setName = fontDef.name ?: return + val setName = fontDef.name ?: return@with - val downloadsDir = File(dataDir, "${Constants.FILES_DIR}/downloads/fonts").asDir() - val setDir = File(downloadsDir, setName).asDir() - FontsState.fontsDownloadsDir = downloadsDir - FontsState.fontsAbsPath = setDir.absolutePath + "/" + val downloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").asDir() + fontsDir = File(downloadsDir, setName).asDir() + fontsDownloadsDir = downloadsDir + fontsAbsPath = fontsDir.absolutePath + "/" // Prune stale font files for this set. - setDir.listFiles()?.forEach { file -> + fontsDir.listFiles()?.forEach { file -> val fileName = file.name if (!fileName.startsWith(".")) { val fontName = fileName.split('.')[0] @@ -99,18 +110,22 @@ object FontsModule : Module() { } } + // Font files are normally downloaded by the JS side; this is a safety net for + // font packs that only ship a fonts.json pointing at remote URLs. CoroutineScope(Dispatchers.IO).launch { fontDef.main.entries.map { (name, url) -> async { try { Log.i("Downloading $name from $url") - val ext = FontsState.FILE_EXTENSIONS.firstOrNull { url.endsWith(it) } ?: ".ttf" - val file = File(setDir, "$name$ext").asFile() + val ext = FILE_EXTENSIONS.firstOrNull { url.endsWith(it) } ?: ".ttf" + val file = File(fontsDir, "$name$ext") if (file.exists()) return@async + val response: HttpResponse = HttpClient(CIO) { install(UserAgent) { agent = Constants.USER_AGENT } install(HttpRedirect) {} }.use { it.get(url) } + if (response.status == HttpStatusCode.OK) { file.writeBytes(response.body()) } @@ -121,66 +136,6 @@ object FontsModule : Module() { }.awaitAll() } } -} - -/** - * Holds the per-process font-state used by the `createAssetTypeface` hijack. - */ -private object FontsState { - val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") - val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") - const val FONTS_ASSET_PATH = "fonts/" - - @Volatile - var fontsDownloadsDir: File? = null - - @Volatile - var fontsAbsPath: String? = null - - fun createAssetTypeface( - rawName: String, - style: Int, - assetManager: AssetManager, - ): Typeface? { - val fontFamilyNames = rawName.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toTypedArray() - - var fontFamilyName = rawName - 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.getOrElse(style) { "" } - - try { - for (fileExt in FILE_EXTENSIONS) { - val split = fontFamilyName.split(":") - if (split.size != 2) break - val (customName, refName) = split - val downloads = fontsDownloadsDir ?: break - val file = File(downloads, "$customName/$refName.$fileExt") - if (!file.exists()) continue - return Typeface.createFromFile(file.absolutePath) - } - } catch (_: Throwable) { - } - - for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { - for (fileExt in FILE_EXTENSIONS) { - val fileName = "$fontRootPath$fontFamilyName$extension$fileExt" - return try { - if (fileName[0] == '/') Typeface.createFromFile(fileName) - else Typeface.createFromAsset(assetManager, fileName) - } catch (_: RuntimeException) { - continue - } - } - } - return Typeface.create(fontFamilyName, style) - } private fun createAssetTypefaceWithFallbacks( fontFamilyNames: Array, @@ -188,7 +143,11 @@ private object FontsState { assetManager: AssetManager, ): Typeface? { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val fontFamilies: MutableList = ArrayList() + + // Iterate over the list of fontFamilyNames, constructing new FontFamily objects + // for use in the CustomFallbackBuilder below. for (fontFamilyName in fontFamilyNames) { try { for (fileExt in FILE_EXTENSIONS) { @@ -221,10 +180,67 @@ private object FontsState { } } + // If there's some problem constructing fonts, fall back to the default behavior. if (fontFamilies.isEmpty()) return createAssetTypeface(fontFamilyNames[0], style, assetManager) val fallbackBuilder = CustomFallbackBuilder(fontFamilies[0]) - for (i in 1 until fontFamilies.size) fallbackBuilder.addCustomFallback(fontFamilies[i]) + for (i in 1 until fontFamilies.size) { + fallbackBuilder.addCustomFallback(fontFamilies[i]) + } return fallbackBuilder.build() } + + private fun createAssetTypeface( + rawName: 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. + val fontFamilyNames = rawName.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toTypedArray() + + var fontFamilyName = rawName + // 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.getOrElse(style) { "" } + + try { + for (fileExt in FILE_EXTENSIONS) { + val split = fontFamilyName.split(":") + if (split.size != 2) break + val (customName, refName) = split + val downloads = fontsDownloadsDir ?: break + val file = File(downloads, "$customName/$refName.$fileExt") + if (!file.exists()) continue + return Typeface.createFromFile(file.absolutePath) + } + } catch (_: Throwable) { + } + + // 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 (fileExt in FILE_EXTENSIONS) { + val fileName = "$fontRootPath$fontFamilyName$extension$fileExt" + return try { + if (fileName[0] == '/') Typeface.createFromFile(fileName) + else Typeface.createFromAsset(assetManager, fileName) + } catch (_: RuntimeException) { + // If the typeface asset does not exist, try another extension. + continue + } + } + } + + return Typeface.create(fontFamilyName, style) + } }