diff --git a/android/app/build.gradle.kts b/android/app/build.gradle.kts index 192ed4be..4ab52a4b 100644 --- a/android/app/build.gradle.kts +++ b/android/app/build.gradle.kts @@ -150,7 +150,6 @@ dependencies { implementation(libs.compose.ui) implementation(libs.compose.ui.graphics) implementation(libs.compose.material3) - implementation(libs.compose.ui.text.google.fonts) debugImplementation(libs.compose.ui.tooling) implementation(libs.compose.ui.tooling.preview) diff --git a/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt index 9eafcfba..373e275d 100644 --- a/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt +++ b/android/app/src/main/java/com/fabledsword/minstrel/theme/Typography.kt @@ -2,72 +2,45 @@ package com.fabledsword.minstrel.theme import androidx.compose.material3.Typography 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.FontStyle 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 com.fabledsword.minstrel.R /** - * Google Fonts provider — fetches font files via Play Services Fonts at - * runtime, caches them across launches. Matches the Flutter client's - * `google_fonts` package behaviour (no bundled .ttf files in either tree). + * Bundled typefaces, vendored into res/font by tools/vendor-fonts.py. + * + * 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: * - Fraunces — display + headline (mythic serif) * - Inter — body + label (clean sans for UI text) * - JetBrains Mono — technical / monospace * 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( - Font( - googleFont = FrauncesFont, - fontProvider = GoogleFontProvider, - weight = FontWeight.W400, - style = FontStyle.Normal, - ), - Font( - googleFont = FrauncesFont, - fontProvider = GoogleFontProvider, - weight = FontWeight.W500, - style = FontStyle.Normal, - ), + Font(R.font.fraunces_regular, FontWeight.W400, FontStyle.Normal), + Font(R.font.fraunces_medium, FontWeight.W500, FontStyle.Normal), ) private val Inter = FontFamily( - Font( - googleFont = InterFont, - fontProvider = GoogleFontProvider, - weight = FontWeight.W400, - style = FontStyle.Normal, - ), - Font( - googleFont = InterFont, - fontProvider = GoogleFontProvider, - weight = FontWeight.W500, - style = FontStyle.Normal, - ), + Font(R.font.inter_regular, FontWeight.W400, FontStyle.Normal), + Font(R.font.inter_medium, FontWeight.W500, FontStyle.Normal), ) private val JetBrainsMono = FontFamily( - Font( - googleFont = JetBrainsMonoFont, - fontProvider = GoogleFontProvider, - weight = FontWeight.W400, - style = FontStyle.Normal, - ), + Font(R.font.jetbrains_mono_regular, FontWeight.W400, FontStyle.Normal), ) /** diff --git a/android/app/src/main/res/font/fraunces_medium.ttf b/android/app/src/main/res/font/fraunces_medium.ttf new file mode 100644 index 00000000..f8479d66 Binary files /dev/null and b/android/app/src/main/res/font/fraunces_medium.ttf differ diff --git a/android/app/src/main/res/font/fraunces_regular.ttf b/android/app/src/main/res/font/fraunces_regular.ttf new file mode 100644 index 00000000..f4e33e48 Binary files /dev/null and b/android/app/src/main/res/font/fraunces_regular.ttf differ diff --git a/android/app/src/main/res/font/inter_medium.ttf b/android/app/src/main/res/font/inter_medium.ttf new file mode 100644 index 00000000..f371b8bd Binary files /dev/null and b/android/app/src/main/res/font/inter_medium.ttf differ diff --git a/android/app/src/main/res/font/inter_regular.ttf b/android/app/src/main/res/font/inter_regular.ttf new file mode 100644 index 00000000..9401a27d Binary files /dev/null and b/android/app/src/main/res/font/inter_regular.ttf differ diff --git a/android/app/src/main/res/font/jetbrains_mono_regular.ttf b/android/app/src/main/res/font/jetbrains_mono_regular.ttf new file mode 100644 index 00000000..c19a0808 Binary files /dev/null and b/android/app/src/main/res/font/jetbrains_mono_regular.ttf differ diff --git a/android/app/src/main/res/values/font_certs.xml b/android/app/src/main/res/values/font_certs.xml deleted file mode 100644 index 66434243..00000000 --- a/android/app/src/main/res/values/font_certs.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - @array/com_google_android_gms_fonts_certs_dev - @array/com_google_android_gms_fonts_certs_prod - - - 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 - - diff --git a/android/gradle/libs.versions.toml b/android/gradle/libs.versions.toml index 5165ea49..176c8c31 100644 --- a/android/gradle/libs.versions.toml +++ b/android/gradle/libs.versions.toml @@ -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-preview = { module = "androidx.compose.ui:ui-tooling-preview" } 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-compiler = { module = "com.google.dagger:hilt-compiler", version.ref = "hilt" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room" } diff --git a/tools/vendor-fonts.py b/tools/vendor-fonts.py index 1e163cbf..310ef760 100644 --- a/tools/vendor-fonts.py +++ b/tools/vendor-fonts.py @@ -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__":