New brand mark, and both clients stop fetching their fonts at runtime #128
@@ -0,0 +1,125 @@
|
||||
package com.fabledsword.minstrel.theme
|
||||
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.io.File
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Guards that the typefaces ship inside the APK instead of being fetched at
|
||||
* runtime.
|
||||
*
|
||||
* Until 2026-09-09 these were resolved through the Play Services font
|
||||
* provider. That needs a network the deployed app is not guaranteed, and a
|
||||
* provider that devices without Play Services do not have at all. Both
|
||||
* failures are silent — text just renders in the platform default, which
|
||||
* reads as a styling regression rather than a missing dependency.
|
||||
*
|
||||
* Expectations are read out of Typography.kt itself rather than hardcoded, so
|
||||
* this cannot drift away from what the app actually declares: adding a face
|
||||
* without vendoring its file fails here, and so does changing a declared
|
||||
* weight without refetching the matching static instance.
|
||||
*/
|
||||
class BundledFontsTest {
|
||||
@Test
|
||||
fun `typography builds its families from bundled resources`() {
|
||||
val source = typographySource()
|
||||
assertTrue(
|
||||
source.contains("R.font."),
|
||||
"Typography.kt should build its families from res/font resources",
|
||||
)
|
||||
FORBIDDEN.forEach { symbol ->
|
||||
assertTrue(
|
||||
!source.contains(symbol),
|
||||
"Typography.kt must not reference $symbol — fonts are bundled, not fetched",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `every declared face is vendored as TrueType at its declared weight`() {
|
||||
val declared = FACE_PATTERN.findAll(typographySource()).toList()
|
||||
assertTrue(
|
||||
declared.isNotEmpty(),
|
||||
"no Font(R.font.…, FontWeight.W…) declarations found — the guard would pass vacuously",
|
||||
)
|
||||
|
||||
declared.forEach { match ->
|
||||
val (name, weight) = match.destructured
|
||||
val file = File(appDir(), "src/main/res/font/$name.ttf")
|
||||
assertTrue(file.isFile, "res/font/$name.ttf is missing — run tools/vendor-fonts.py")
|
||||
|
||||
val bytes = file.readBytes()
|
||||
assertTrue(
|
||||
bytes.copyOfRange(0, TTF_MAGIC.size).contentEquals(TTF_MAGIC),
|
||||
"$name.ttf is not TrueType — res/font cannot load a woff2 or an eot",
|
||||
)
|
||||
// The decisive check. Google's css2 endpoint silently collapses a
|
||||
// multi-weight request to 400 for legacy clients, so Medium can
|
||||
// come back as Regular: a valid TrueType file that renders at the
|
||||
// wrong weight everywhere. usWeightClass is the only field that
|
||||
// tells the two apart.
|
||||
assertEquals(
|
||||
weight.toInt(),
|
||||
weightClass(bytes),
|
||||
"$name.ttf carries a different OS/2 usWeightClass than the FontWeight declared beside it",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Typography.kt with comments removed, so prose naming the forbidden
|
||||
* symbols cannot satisfy — or trip — the absence check above. */
|
||||
private fun typographySource(): String =
|
||||
File(appDir(), TYPOGRAPHY)
|
||||
.readText()
|
||||
.replace(BLOCK_COMMENT, "")
|
||||
.replace(LINE_COMMENT, "")
|
||||
|
||||
/** Gradle's working directory for tests is the module dir, but don't rely
|
||||
* on it: walk up until the module is found, and say so if it isn't. */
|
||||
private fun appDir(): File {
|
||||
var dir: File? = File("").absoluteFile
|
||||
while (dir != null) {
|
||||
if (File(dir, TYPOGRAPHY).isFile) return dir
|
||||
if (File(dir, "app/$TYPOGRAPHY").isFile) return File(dir, "app")
|
||||
dir = dir.parentFile
|
||||
}
|
||||
error("could not locate the app module from ${File("").absolutePath}")
|
||||
}
|
||||
|
||||
private fun weightClass(bytes: ByteArray): Int {
|
||||
val tables = readU16(bytes, NUM_TABLES)
|
||||
for (i in 0 until tables) {
|
||||
val record = TABLE_DIRECTORY + i * TABLE_RECORD
|
||||
if (String(bytes, record, TAG_LENGTH, Charsets.US_ASCII) == "OS/2") {
|
||||
return readU16(bytes, readU32(bytes, record + OFFSET_FIELD) + WEIGHT_FIELD)
|
||||
}
|
||||
}
|
||||
error("no OS/2 table in the font")
|
||||
}
|
||||
|
||||
private fun readU16(bytes: ByteArray, at: Int): Int =
|
||||
((bytes[at].toInt() and BYTE_MASK) shl Byte.SIZE_BITS) or (bytes[at + 1].toInt() and BYTE_MASK)
|
||||
|
||||
private fun readU32(bytes: ByteArray, at: Int): Int =
|
||||
(readU16(bytes, at) shl Short.SIZE_BITS) or readU16(bytes, at + 2)
|
||||
|
||||
private companion object {
|
||||
const val TYPOGRAPHY = "src/main/java/com/fabledsword/minstrel/theme/Typography.kt"
|
||||
|
||||
val FORBIDDEN = listOf("GoogleFont", "googlefonts")
|
||||
val FACE_PATTERN = Regex("""R\.font\.(\w+)\s*,\s*FontWeight\.W(\d+)""")
|
||||
val BLOCK_COMMENT = Regex("""/\*[\s\S]*?\*/""")
|
||||
val LINE_COMMENT = Regex("""//.*""")
|
||||
val TTF_MAGIC = byteArrayOf(0x00, 0x01, 0x00, 0x00)
|
||||
|
||||
// Offsets into the TrueType table directory, per the OpenType spec.
|
||||
const val NUM_TABLES = 4
|
||||
const val TABLE_DIRECTORY = 12
|
||||
const val TABLE_RECORD = 16
|
||||
const val TAG_LENGTH = 4
|
||||
const val OFFSET_FIELD = 8
|
||||
const val WEIGHT_FIELD = 4
|
||||
const val BYTE_MASK = 0xFF
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user