fix(android): bundle the typefaces instead of fetching them at runtime
android / Build + lint + test (push) Successful in 4m19s
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:
+81
-5
@@ -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__":
|
||||
|
||||
Reference in New Issue
Block a user