fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s

Typography.kt resolved Fraunces, Inter and JetBrains Mono through the Play
Services font provider, which fetches them over the network on first use.
Same rule-164 problem the web client had, with a second failure mode on
top: the provider is absent entirely on devices without Play Services, so
the app fell back to the platform default and stopped looking like
Minstrel — quietly, with no error.

The five static instances now live in res/font, vendored by the same
tools/vendor-fonts.py that produces the web bundle. Both clients draw
from one list of faces so they cannot drift apart. Cost is ~0.86 MB of
APK; the runtime path is removed rather than kept as a fallback — the
ui-text-google-fonts dependency, its version-catalog entry and the
provider certificate hashes in font_certs.xml are all gone.

Two things about fetching TTFs that are worth writing down, because both
fail by succeeding:

Google Fonts picks the format from the User-Agent, and there is no
parameter to ask for one. A modern UA gets woff2, which res/font cannot
load. The obvious "use an old UA" fix gets EOT — an IE-only format that
downloads happily, has a plausible size, and is entirely useless here. An
Android 4.4 UA is what actually yields TrueType.

css2 also collapses a multi-weight request to 400 for legacy clients, so
asking for Medium silently returns Regular: a valid TrueType file that
renders at the wrong weight everywhere. Each weight is therefore fetched
on its own URL, and the script now asserts OS/2 usWeightClass on every
download — that field is the only thing distinguishing the two files.

Verified before wiring: all five carry TrueType magic, the 400/500 pairs
differ, and their usWeightClass reads 400/400/500/500/400 as declared
beside them in the FontFamily.

Not covered: there is no guard for this on the Android side. The web
equivalent is asserted by no-external-assets.test.ts, but the Android
tree has no source-inspection test pattern to follow and no way to
falsify one without a local Gradle run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
This commit is contained in:
2026-09-09 14:13:57 -04:00
co-authored by Claude Opus 5
parent 16005054eb
commit b52a00df66
10 changed files with 99 additions and 68 deletions
-1
View File
@@ -150,7 +150,6 @@ dependencies {
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.compose.ui.graphics) implementation(libs.compose.ui.graphics)
implementation(libs.compose.material3) implementation(libs.compose.material3)
implementation(libs.compose.ui.text.google.fonts)
debugImplementation(libs.compose.ui.tooling) debugImplementation(libs.compose.ui.tooling)
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
@@ -2,72 +2,45 @@ package com.fabledsword.minstrel.theme
import androidx.compose.material3.Typography import androidx.compose.material3.Typography
import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.googlefonts.Font
import androidx.compose.ui.text.googlefonts.GoogleFont
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.fabledsword.minstrel.R import com.fabledsword.minstrel.R
/** /**
* Google Fonts provider — fetches font files via Play Services Fonts at * Bundled typefaces, vendored into res/font by tools/vendor-fonts.py.
* runtime, caches them across launches. Matches the Flutter client's *
* `google_fonts` package behaviour (no bundled .ttf files in either tree). * These were fetched at runtime through the Play Services font provider until
* 2026-09-09. That is a network dependency for rendering, and a deployed
* instance is not guaranteed one — the provider is also absent entirely on
* devices without Play Services, where the app silently fell back to the
* platform default and stopped looking like Minstrel. Bundling costs ~0.86 MB
* of APK and removes both failure modes.
* *
* Per FabledSword design system: * Per FabledSword design system:
* - Fraunces — display + headline (mythic serif) * - Fraunces — display + headline (mythic serif)
* - Inter — body + label (clean sans for UI text) * - Inter — body + label (clean sans for UI text)
* - JetBrains Mono — technical / monospace * - JetBrains Mono — technical / monospace
* Weights are restricted to 400 (regular) and 500 (medium) only. * Weights are restricted to 400 (regular) and 500 (medium) only.
*
* Each res/font entry is a single static instance, not a variable font: the
* weight declared beside it here must match the file's own OS/2
* usWeightClass, which the vendoring script asserts on download.
*/ */
private val GoogleFontProvider = GoogleFont.Provider(
providerAuthority = "com.google.android.gms.fonts",
providerPackage = "com.google.android.gms",
certificates = R.array.com_google_android_gms_fonts_certs,
)
private val FrauncesFont = GoogleFont("Fraunces")
private val InterFont = GoogleFont("Inter")
private val JetBrainsMonoFont = GoogleFont("JetBrains Mono")
private val Fraunces = FontFamily( private val Fraunces = FontFamily(
Font( Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal),
googleFont = FrauncesFont, Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal),
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
Font(
googleFont = FrauncesFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W500,
style = FontStyle.Normal,
),
) )
private val Inter = FontFamily( private val Inter = FontFamily(
Font( Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal),
googleFont = InterFont, Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal),
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
Font(
googleFont = InterFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W500,
style = FontStyle.Normal,
),
) )
private val JetBrainsMono = FontFamily( private val JetBrainsMono = FontFamily(
Font( Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal),
googleFont = JetBrainsMonoFont,
fontProvider = GoogleFontProvider,
weight = FontWeight.W400,
style = FontStyle.Normal,
),
) )
/** /**
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Google Fonts provider certificate hashes for downloadable fonts via
androidx.compose.ui.text.googlefonts.GoogleFont.Provider. Standard
values published by Google; copied verbatim from the AndroidX docs. -->
<resources>
<array name="com_google_android_gms_fonts_certs">
<item>@array/com_google_android_gms_fonts_certs_dev</item>
<item>@array/com_google_android_gms_fonts_certs_prod</item>
</array>
<string-array name="com_google_android_gms_fonts_certs_dev">
<item>MIIEqDCCA5CgAwIBAgIJANWFuGx90071MA0GCSqGSIb3DQEBBAUAMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTAeFw0wODA0MTUyMzM2NTZaFw0zNTA5MDEyMzM2NTZaMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbTCCASAwDQYJKoZIhvcNAQEBBQADggENADCCAQgCggEBANbOLggKv+IxTdGNs8/TGFy0PTP6DHThvbbR24kT9ixcOd9W+EaBPWW+wPPKQmsHxajtWjmQwWfna8mZuSeJS48LIgAZlKkpoyLcfobBPv6yyz8x1IxWWmF9c1IGN3vSL6BLNJEUyMEPzC2WZdwT4ZG2cuJTtzeETl6jWFKx68ETtZxNVHe9Iy9NMxEljDqVZ4y6+FlHaiYJqq3LcJpJVuKYz4kvOcyf3M0nDA8mUlVdfsOlw/H4uoNQ7VrAQUKB4kAyfxsKp/RZmnZSJ7+8Ag9aTC+oguTd1iFNuMqDUlpePo6CGuh73iKuq8mYvtdQQ0Yz+mF4j2YWB7Gj0R1k2cCAQOjgfwwgfkwHQYDVR0OBBYEFI0cxb6VTEM8YYY6FbBMvAPyT+CyMIHJBgNVHSMEgcEwgb6AFI0cxb6VTEM8YYY6FbBMvAPyT+CyoYGapIGXMIGUMQswCQYDVQQGEwJVUzETMBEGA1UECBMKQ2FsaWZvcm5pYTEWMBQGA1UEBxMNTW91bnRhaW4gVmlldzEQMA4GA1UEChMHQW5kcm9pZDEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDEiMCAGCSqGSIb3DQEJARYTYW5kcm9pZEBhbmRyb2lkLmNvbYIJANWFuGx90071MAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABnTDPEF+3iSP0wNfdIjIz1AlnrPzgAIHVvXxunW7SBrDhEglQZBbKJEk5kT0mtKoOD1JMrSu1xuTKEBahWRbqHsXclaXjoBADb0kkjVEJu/Lh5hgYZnOjvlba8Ld7HCKePCVePoTJBdI4fvugnL8TsgK05aIskyY0hKI9L8KfqfGTl1lzOv2KoWD0KWwtAWPoGChZxmQ+nBli+gwYMzM1vAkP+aayLe0a1EQimlOalO762r0GXO0ks+UeXde2Z4e+8S/pf7pITEI/tP+MxJTALw9QUWEv9lKTk+jkbqxbsh8nfBUapfKqYn0eidpwq2AzVp3juYl7//fKnaPhJD9gs=</item>
</string-array>
<string-array name="com_google_android_gms_fonts_certs_prod">
<item>MIIEQzCCAyugAwIBAgIJAMLgh0ZkSjCNMA0GCSqGSIb3DQEBBAUAMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDAeFw0wODA4MjEyMzEzMzRaFw0zNjAxMDcyMzEzMzRaMHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZDCCASIwDQYJKoZIhvcNAQEBBQADggEPADCCAQoCggEBAKtWLgDYO6IIrgqWbxJOKdoR8qtW0I9Y4sypEwPpt1TTcvZApxsdyxMJZ2JORland2qSGT2y5b+3JKkedxiLDmpHpDsz2WCbdxgxRczfey5YZnTJ4VZbH0xqWVW/8lGmPav5xVwnIiJS6HXk+BVKZF+JcWjAsb/GEuq/eFdpuzSqeYTcfi6idkyugwfYwXFU1+5fZKUaRKYCwkkFQVfcAs1fXA5V+++FGfvjJ/CxURaSxaBvGdGDhfXE28LWuT9ozCl5xw4Yq5OGazvV24mZVSoOO0yZ31j7kYvtwYK6NeADwbSxDdJEqO4k//0zOHKrUiGYXtqw/A0LFFtqoZKFjnkCAwEAAaOB1zCB1DAdBgNVHQ4EFgQUhzkS9E6G+x8U7eIYZVgWyN4j2u4wgaQGA1UdIwSBnDCBmYAUhzkS9E6G+x8U7eIYZVgWyN4j2u6heKR2MHQxCzAJBgNVBAYTAlVTMRMwEQYDVQQIEwpDYWxpZm9ybmlhMRYwFAYDVQQHEw1Nb3VudGFpbiBWaWV3MRQwEgYDVQQKEwtHb29nbGUgSW5jLjEQMA4GA1UECxMHQW5kcm9pZDEQMA4GA1UEAxMHQW5kcm9pZIIJAMLgh0ZkSjCNMAwGA1UdEwQFMAMBAf8wDQYJKoZIhvcNAQEEBQADggEBABywqUAtNkXf2EVQuRGiI3pnNvIYx7N5xj4LMtloEdEqMpEcMa6Qe87qDx2hsArOR1nzQAFGsT/8YIIfX0fAJjQuP1lAcExSxVKbFICEvFBaWuhGgOOZ7CYzfHB6tEzJFLR2DQHQrXLT2HKDDhxhe9hKzqIRDSc5Hjr3jY5MMzfYM5lFvKK9pLqEsP6/Ad9SDhupcVoOWVrSCNKfRb6jpJbZuxJhCnq8tmlV4iy5tEW0a3VBYzpRoBdAaORWqHQTUlt+iL3aH7C5OxhgN/JuxvxXBL/3kkc0wK1ZNuk+sb4lNXmHnVqQYTcyowQHRPCRsPzCCl4ANULRpZjxAd0xUgg=</item>
</string-array>
</resources>
-1
View File
@@ -53,7 +53,6 @@ compose-ui-graphics = { module = "androidx.compose.ui:ui-graphics" }
compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" } compose-ui-tooling = { module = "androidx.compose.ui:ui-tooling" }
compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" } compose-ui-tooling-preview = { module = "androidx.compose.ui:ui-tooling-preview" }
compose-material3 = { module = "androidx.compose.material3:material3" } compose-material3 = { module = "androidx.compose.material3:material3" }
compose-ui-text-google-fonts = { module = "androidx.compose.ui:ui-text-google-fonts" }
hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" } hilt-android = { module = "com.google.dagger:hilt-android", version.ref = "hilt" }
hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } hilt-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" }
room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" }
+81 -5
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Download the web fonts from Google Fonts and vendor them into the repo. """Download the fonts from Google Fonts and vendor them into the repo.
Covers BOTH clients — the web bundle (woff2, subsetted) and the Android app
(static ttf, whole-font) — because they draw from the same three families and
letting them drift is how one of them quietly stops matching the other.
Run by hand when the font set changes, never at build or run time: Run by hand when the font set changes, never at build or run time:
@@ -22,12 +26,14 @@ request bytes, and a library full of non-English artist names renders instead
of falling back mid-list. of falling back mid-list.
""" """
import re import re
import struct
import sys import sys
import urllib.request import urllib.request
from pathlib import Path from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent ROOT = Path(__file__).resolve().parent.parent
OUT = ROOT / "web/static/fonts" OUT = ROOT / "web/static/fonts"
ANDROID_OUT = ROOT / "android/app/src/main/res/font"
# Matches the faces the design system actually permits: two weights only, 400 # Matches the faces the design system actually permits: two weights only, 400
# and 500 (never 600/700), and Fraunces' optical-size axis across its range. # and 500 (never 600/700), and Fraunces' optical-size axis across its range.
@@ -38,18 +44,86 @@ FAMILIES = (
) )
CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap" CSS_URL = f"https://fonts.googleapis.com/css2?{FAMILIES}&display=swap"
# A modern browser UA is required, not cosmetic: Google serves ancient TTF to # The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
# unrecognised clients and woff2 only to browsers known to support it. # per client, and there is no parameter to ask for one directly.
# modern Chrome -> woff2 (what the browser wants)
# old Android -> ttf (what Android's res/font requires)
# MSIE 6 -> eot (an IE-only format; the obvious "old UA" choice
# and completely useless here — it downloads and
# looks plausible until you check the magic bytes)
UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 " UA = ("Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36") "(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36")
UA_TTF = ("Mozilla/5.0 (Linux; U; Android 4.4.2; en-us) AppleWebKit/534.30 "
"(KHTML, like Gecko) Version/4.0 Mobile Safari/534.30")
# Android res/font resource names: lowercase, digits and underscore only.
# Each entry is one static weight — Compose selects by the FontWeight declared
# alongside it in Typography.kt, so the file must genuinely be that instance.
# css2 collapses a multi-weight request to 400 for legacy clients, so each
# weight is fetched on its own URL.
ANDROID_FACES = [
("fraunces_regular", "Fraunces:opsz,wght@9..144,400", 400),
("fraunces_medium", "Fraunces:opsz,wght@9..144,500", 500),
("inter_regular", "Inter:wght@400", 400),
("inter_medium", "Inter:wght@500", 500),
("jetbrains_mono_regular", "JetBrains+Mono:wght@400", 400),
]
TTF_MAGIC = (b"\x00\x01\x00\x00", b"true", b"OTTO")
def fetch(url, timeout=30): def weight_class(data):
req = urllib.request.Request(url, headers={"User-Agent": UA}) """Read OS/2 usWeightClass out of a TrueType file.
Worth the twenty lines: css2 silently collapses a multi-weight request to
400 for legacy clients, so asking for Medium and getting Regular is a real
and quiet failure. The file downloads, has valid TrueType magic, and
renders — just at the wrong weight, everywhere, forever. This is the only
field that actually distinguishes them.
"""
count = struct.unpack(">H", data[4:6])[0]
for i in range(count):
off = 12 + i * 16
if data[off:off + 4] == b"OS/2":
table = struct.unpack(">I", data[off + 8:off + 12])[0]
return struct.unpack(">H", data[table + 4:table + 6])[0]
return None
def fetch(url, timeout=30, ttf=False):
req = urllib.request.Request(url, headers={"User-Agent": UA_TTF if ttf else UA})
with urllib.request.urlopen(req, timeout=timeout) as r: with urllib.request.urlopen(req, timeout=timeout) as r:
return r.read() return r.read()
def vendor_android():
"""Fetch static TTFs for the Android client's res/font."""
ANDROID_OUT.mkdir(parents=True, exist_ok=True)
for old in ANDROID_OUT.glob("*.ttf"):
old.unlink()
total = 0
for name, spec, want_weight in ANDROID_FACES:
css = fetch(f"https://fonts.googleapis.com/css2?family={spec}", ttf=True).decode()
url = re.search(r"url\((https://[^)]+)\)", css)
if not url:
sys.exit(f"no font url for {spec}")
data = fetch(url.group(1), ttf=True)
# Assert the FORMAT, because every wrong one still downloads happily
# and only fails later, on a device, as a silently missing typeface.
if not data.startswith(TTF_MAGIC):
sys.exit(f"{name}: expected TrueType, got magic {data[:4].hex()} "
f"(eot/woff means the UA negotiation broke)")
got = weight_class(data)
if got != want_weight:
sys.exit(f"{name}: wanted weight {want_weight}, file reports {got} "
f"(css2 collapsed the request to a single weight)")
(ANDROID_OUT / f"{name}.ttf").write_bytes(data)
total += len(data)
print(f" {name + '.ttf':<30} {len(data)/1024:7.1f} KB weight {got}")
print(f"{len(ANDROID_FACES)} ttf, {total/1024/1024:.2f} MB total -> "
f"{ANDROID_OUT.relative_to(ROOT)}")
def main(): def main():
css = fetch(CSS_URL).decode() css = fetch(CSS_URL).decode()
@@ -90,6 +164,8 @@ def main():
for n, size in sorted(seen.items()): for n, size in sorted(seen.items()):
print(f" {n:<44} {size/1024:7.1f} KB") print(f" {n:<44} {size/1024:7.1f} KB")
print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}") print(f"{len(seen)} files, {total/1024/1024:.2f} MB total -> {OUT.relative_to(ROOT)}")
print()
vendor_android()
if __name__ == "__main__": if __name__ == "__main__":