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
+81 -5
View File
@@ -1,5 +1,9 @@
#!/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:
@@ -22,12 +26,14 @@ request bytes, and a library full of non-English artist names renders instead
of falling back mid-list.
"""
import re
import struct
import sys
import urllib.request
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
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
# 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"
# A modern browser UA is required, not cosmetic: Google serves ancient TTF to
# unrecognised clients and woff2 only to browsers known to support it.
# The UA is load-bearing, not cosmetic: Google Fonts serves a different FORMAT
# 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 "
"(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):
req = urllib.request.Request(url, headers={"User-Agent": UA})
def weight_class(data):
"""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:
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():
css = fetch(CSS_URL).decode()
@@ -90,6 +164,8 @@ def main():
for n, size in sorted(seen.items()):
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()
vendor_android()
if __name__ == "__main__":