From baacefef544d44636deb030456566f24d580583d Mon Sep 17 00:00:00 2001 From: OTKUSteyler Date: Sat, 5 Sep 2026 19:46:48 +0200 Subject: [PATCH] Update FontsModule.kt --- .../xposed/modules/appearance/FontsModule.kt | 282 ++++++++---------- 1 file changed, 128 insertions(+), 154 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 d3bbf38..2b1b91f 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,4 +1,6 @@ -package GoonXposed.xposed.modules.appearance +@file:Suppress("DEPRECATION") + +package GoonXposed.xposed.tweaks.appearance import android.content.res.AssetManager import android.graphics.Typeface @@ -6,26 +8,15 @@ 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_MethodHook.MethodHookParam -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 -import GoonXposed.xposed.Utils.Companion.JSON -import GoonXposed.xposed.Utils.Log -import GoonXposed.xposed.asDir -import GoonXposed.xposed.asFile -import io.ktor.client.* +import GoonXposed.Logger +import GoonXposed.xposed.* +import GoonXposed.xposed.tweaks.PayloadBuilder 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 @@ -38,166 +29,107 @@ data class FontDefinition( val main: Map, ) -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/" +/** + * Custom font loading + ReactFontManager hijack. + */ +val fonts by tweak { + val log: Logger = this.log - private lateinit var fontsDir: File - private lateinit var fontsDownloadsDir: File - private var fontsAbsPath: String? = null + PayloadBuilder.contribute { put("fontPatch", 2) } - override fun buildPayload(builder: JsonObjectBuilder) { - builder.apply { - put("fontPatch", 2) + // ReactFontManager hijack runs regardless of fonts.json presence it falls back to the default Typeface chain if no custom font file is found. + listOf( + "com.facebook.react.common.assets.ReactFontManager\$Companion", + "com.facebook.react.views.text.ReactFontManager\$Companion", + ).forEach { clsName -> + classLoader.loadClassOrNull(clsName)?.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) + } + } + } } } - override fun onLoad(packageParam: XC_LoadPackage.LoadPackageParam) = with(packageParam) { - XposedHelpers.findAndHookMethod( - "com.facebook.react.common.assets.ReactFontManager\$Companion", - 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, "${Constants.FILES_DIR}/fonts.json").apply { asFile() } - if (!fontDefFile.exists()) return@with + withAppContext { ctx -> + val dataDir = ctx.dataDir.absolutePath + val fontDefFile = File(dataDir, "${Constants.FILES_DIR}/fonts.json").apply { ensureFile() } + if (!fontDefFile.exists()) return@withAppContext val fontDef = try { - JSON.decodeFromString(fontDefFile.readText()) - } catch (_: Throwable) { - return@with + Json.decodeFromString(fontDefFile.readText()) + } catch (e: Throwable) { + log.w("fonts.json malformed: ${e.message}") + return@withAppContext } + val setName = fontDef.name ?: return@withAppContext - val fontName = fontDef.name ?: return@with - fontsDownloadsDir = File(appInfo.dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { asDir() } - fontsDir = File(fontsDownloadsDir, fontName).apply { asDir() } - fontsAbsPath = fontsDir.absolutePath + "/" + val downloadsDir = File(dataDir, "${Constants.FILES_DIR}/downloads/fonts").apply { ensureDir() } + val setDir = File(downloadsDir, setName).apply { ensureDir() } + FontsState.fontsDownloadsDir = downloadsDir + FontsState.fontsAbsPath = setDir.absolutePath + "/" - fontsDir.listFiles()?.forEach { file -> + // Prune stale font files for this set. + setDir.listFiles()?.forEach { file -> val fileName = file.name if (!fileName.startsWith(".")) { val fontName = fileName.split('.')[0] if (fontDef.main.keys.none { it == fontName }) { - Log.i("Deleting font file: $fileName") + log.i("Deleting stale 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 -> + fontDef.main.entries.map { (name, url) -> async { - val url = fontDef.main.getValue(name) try { - Log.i("Downloading $name from $url") - val file = - File(fontsDir, "$name${FILE_EXTENSIONS.first { url.endsWith(it) }}").apply { asFile() } + log.i("Downloading $name from $url") + val ext = FontsState.FILE_EXTENSIONS.firstOrNull { url.endsWith(it) } ?: ".ttf" + val file = File(setDir, "$name$ext").apply { ensureFile() } if (file.exists()) return@async - - val client = HttpClient(CIO) { - install(UserAgent) { agent = Constants.USER_AGENT } + val response: HttpResponse = httpClient.get(url) + if (response.status == HttpStatusCode.OK) { + file.writeBytes(response.body()) } - - val response: HttpResponse = client.get(url) - if (response.status == HttpStatusCode.OK) file.writeBytes(response.body()) - - return@async } catch (e: Throwable) { - Log.e("Failed to download fonts ($name from $url)", e) + log.e("Failed to download font ($name from $url)", e) } } }.awaitAll() } } +} - private fun splitFontName(fontFamilyName: String): Pair { - val parts = fontFamilyName.split(":") - return if (parts.size >= 2) parts[0] to parts[1] else "" to fontFamilyName - } +/** + * Holds the per-process font-state used by the `createAssetTypeface` hijack. Mirrors the static + * fields of the old `FontsModule`. + */ +private object FontsState { + val EXTENSIONS = arrayOf("", "_bold", "_italic", "_bold_italic") + val FILE_EXTENSIONS = arrayOf(".ttf", ".otf") + const val FONTS_ASSET_PATH = "fonts/" - private fun fontFileFor(customName: String, refName: String, fileExtension: String): File = - if (customName.isEmpty()) File(fontsDownloadsDir, "$refName.$fileExtension") - else File(fontsDownloadsDir, "$customName/$refName.$fileExtension") + @Volatile + var fontsDownloadsDir: File? = null - private fun createAssetTypefaceWithFallbacks( - fontFamilyNames: Array, style: Int, assetManager: AssetManager + @Volatile + var fontsAbsPath: String? = null + + fun createAssetTypeface( + rawName: String, + 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) = splitFontName(fontFamilyName) - val file = fontFileFor(customName, refName, fileExtension).apply { asFile() } - val font = Font.Builder(file).build() - val family = FontFamily.Builder(font).build() - fontFamilies.add(family) - } - } catch (_: Throwable) { - } + val fontFamilyNames = rawName.split(",").map { it.trim() }.filter { it.isNotEmpty() }.toTypedArray() - for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { - for (fileExtension in FILE_EXTENSIONS) { - val fileName = - 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 (_: RuntimeException) { - // If the typeface asset does not exist, try another extension. - continue - } catch (_: IOException) { - // If the typeface asset does not exist, try another extension. - continue - } - } - } - } - - // 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]) - } - 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 + var fontFamilyName = rawName if (fontFamilyNames.size > 1) { fontFamilyName = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { return createAssetTypefaceWithFallbacks(fontFamilyNames, style, assetManager) @@ -206,36 +138,78 @@ object FontsModule : Module() { } } - val extension = EXTENSIONS[style] + val extension = EXTENSIONS.getOrElse(style) { "" } try { - for (fileExtension in FILE_EXTENSIONS) { - val (customName, refName) = splitFontName(fontFamilyName) - val file = fontFileFor(customName, refName, fileExtension).apply { asFile() } - if (!file.exists()) throw Exception() + 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").apply { ensureFile() } + 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 (fileExtension in FILE_EXTENSIONS) { - val fileName = - StringBuilder().append(fontRootPath).append(fontFamilyName).append(extension).append(fileExtension) - .toString() - + 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) } + + private fun createAssetTypefaceWithFallbacks( + fontFamilyNames: Array, + style: Int, + assetManager: AssetManager, + ): Typeface? { + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return null + val fontFamilies: MutableList = ArrayList() + for (fontFamilyName in fontFamilyNames) { + 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").apply { ensureFile() } + if (!file.exists()) continue + val font = Font.Builder(file).build() + fontFamilies.add(FontFamily.Builder(font).build()) + } + } catch (_: Throwable) { + } + + for (fontRootPath in arrayOf(fontsAbsPath, FONTS_ASSET_PATH).filterNotNull()) { + for (fileExt in FILE_EXTENSIONS) { + val fileName = "$fontRootPath$fontFamilyName$fileExt" + try { + val builder = if (fileName[0] == '/') Font.Builder(File(fileName)) + else Font.Builder(assetManager, fileName) + val font = builder.build() + fontFamilies.add(FontFamily.Builder(font).build()) + } catch (_: RuntimeException) { + continue + } catch (_: IOException) { + continue + } + } + } + } + + 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]) + return fallbackBuilder.build() + } }