Total ≈ 1.49 MB (under the ≲2 MB budget). Merriweather ships as a static Regular cut because its variable font is 4.4 MB (opsz+wdth+wght). Each family's OFL.txt is committed under THIRD_PARTY_LICENSES/, and *.ttf/*.otf are marked binary in .gitattributes so the bytes commit intact. Every TTF was verified (0001 0000 magic, real weight tables) and every OFL confirmed to be license text, not an HTML error page.
FontRegistry (ui/compose/format/): display name ↔ email-safe CSS stack ↔ Compose FontFamily, with three generic Sans/Serif/Monospace entries needing no bundled file. Each bundled stack names the family first then a generic fallback (e.g. 'Lora', Georgia, serif) so recipients without the face fall back down the stack. resolveFontFamily is passed into RichTextBodyField from ComposeScreen so styled runs render in-editor.
FontPicker (ui/compose/format/FontPicker.kt): a toolbar dropdown applying RichStyle.FontFamily(css) via the generalized applyStyle/clearStyle path, with a leading "Default" entry that clears it; each menu entry previews itself in its own face. Appended at the end of the toolbar (with the size/alignment controls), after the block/link buttons — per the #73/#76 lesson, nothing may shift the bullet/numbered/quote buttons that ComposeScreenTest taps without scrolling.
Test plan
:app:assembleDebug (fonts are valid resources; APK builds)
:app:testDebugUnitTest — FontRegistryTest: every CSS stack survives serialize→parse round-trip; resolveFontFamily maps known stacks and rejects unknown; bundled stacks end in a generic fallback (recipient fallback); displayNameFor lookups.
:app:lintDebug (no font/resource lint issues)
:app:ktlintCheck :app:detekt
:app:compileDebugAndroidTestKotlin — compile-only FontPickerTest drives the picker in isolation (default label, current-font label, menu lists every registry font, picking reports its CSS / Default clears).
## Summary
Adds a font-family picker to the compose formatting toolbar, backed by four bundled open-source fonts.
- **Bundled fonts** (`res/font/`, SIL OFL 1.1, no build-time downloads — F-Droid safe), fetched from the `google/fonts` / official upstreams:
| file | family | source | size |
|---|---|---|---|
| `inter.ttf` | Inter | `google/fonts` variable `Inter[opsz,wght]` | 876,576 B |
| `lora.ttf` | Lora | `google/fonts` variable `Lora[wght]` | 212,196 B |
| `jetbrains_mono.ttf` | JetBrains Mono | `google/fonts` variable `JetBrainsMono[wght]` | 187,208 B |
| `merriweather.ttf` | Merriweather | `SorkinType/Merriweather` static `Merriweather-Regular` | 282,844 B |
**Total ≈ 1.49 MB** (under the ≲2 MB budget). Merriweather ships as a static Regular cut because its variable font is 4.4 MB (opsz+wdth+wght). Each family's `OFL.txt` is committed under `THIRD_PARTY_LICENSES/`, and `*.ttf`/`*.otf` are marked `binary` in `.gitattributes` so the bytes commit intact. Every TTF was verified (`0001 0000` magic, real weight tables) and every OFL confirmed to be license text, not an HTML error page.
- **`FontRegistry`** (`ui/compose/format/`): display name ↔ email-safe CSS stack ↔ Compose `FontFamily`, with three generic Sans/Serif/Monospace entries needing no bundled file. Each bundled stack names the family first then a generic fallback (e.g. `'Lora', Georgia, serif`) so recipients without the face fall back down the stack. `resolveFontFamily` is passed into `RichTextBodyField` from `ComposeScreen` so styled runs render in-editor.
- **`FontPicker`** (`ui/compose/format/FontPicker.kt`): a toolbar dropdown applying `RichStyle.FontFamily(css)` via the generalized `applyStyle`/`clearStyle` path, with a leading "Default" entry that clears it; each menu entry previews itself in its own face. Appended at the **end** of the toolbar (with the size/alignment controls), after the block/link buttons — per the #73/#76 lesson, nothing may shift the bullet/numbered/quote buttons that `ComposeScreenTest` taps without scrolling.
## Test plan
- [x] `:app:assembleDebug` (fonts are valid resources; APK builds)
- [x] `:app:testDebugUnitTest` — `FontRegistryTest`: every CSS stack survives serialize→parse round-trip; `resolveFontFamily` maps known stacks and rejects unknown; bundled stacks end in a generic fallback (recipient fallback); `displayNameFor` lookups.
- [x] `:app:lintDebug` (no font/resource lint issues)
- [x] `:app:ktlintCheck :app:detekt`
- [x] `:app:compileDebugAndroidTestKotlin` — compile-only `FontPickerTest` drives the picker in isolation (default label, current-font label, menu lists every registry font, picking reports its CSS / Default clears).
Emulator E2E left to CI.
Closes #72
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Summary
Adds a font-family picker to the compose formatting toolbar, backed by four bundled open-source fonts.
Bundled fonts (
res/font/, SIL OFL 1.1, no build-time downloads — F-Droid safe), fetched from thegoogle/fonts/ official upstreams:inter.ttfgoogle/fontsvariableInter[opsz,wght]lora.ttfgoogle/fontsvariableLora[wght]jetbrains_mono.ttfgoogle/fontsvariableJetBrainsMono[wght]merriweather.ttfSorkinType/MerriweatherstaticMerriweather-RegularTotal ≈ 1.49 MB (under the ≲2 MB budget). Merriweather ships as a static Regular cut because its variable font is 4.4 MB (opsz+wdth+wght). Each family's
OFL.txtis committed underTHIRD_PARTY_LICENSES/, and*.ttf/*.otfare markedbinaryin.gitattributesso the bytes commit intact. Every TTF was verified (0001 0000magic, real weight tables) and every OFL confirmed to be license text, not an HTML error page.FontRegistry(ui/compose/format/): display name ↔ email-safe CSS stack ↔ ComposeFontFamily, with three generic Sans/Serif/Monospace entries needing no bundled file. Each bundled stack names the family first then a generic fallback (e.g.'Lora', Georgia, serif) so recipients without the face fall back down the stack.resolveFontFamilyis passed intoRichTextBodyFieldfromComposeScreenso styled runs render in-editor.FontPicker(ui/compose/format/FontPicker.kt): a toolbar dropdown applyingRichStyle.FontFamily(css)via the generalizedapplyStyle/clearStylepath, with a leading "Default" entry that clears it; each menu entry previews itself in its own face. Appended at the end of the toolbar (with the size/alignment controls), after the block/link buttons — per the #73/#76 lesson, nothing may shift the bullet/numbered/quote buttons thatComposeScreenTesttaps without scrolling.Test plan
:app:assembleDebug(fonts are valid resources; APK builds):app:testDebugUnitTest—FontRegistryTest: every CSS stack survives serialize→parse round-trip;resolveFontFamilymaps known stacks and rejects unknown; bundled stacks end in a generic fallback (recipient fallback);displayNameForlookups.:app:lintDebug(no font/resource lint issues):app:ktlintCheck :app:detekt:app:compileDebugAndroidTestKotlin— compile-onlyFontPickerTestdrives the picker in isolation (default label, current-font label, menu lists every registry font, picking reports its CSS / Default clears).Emulator E2E left to CI.
Closes #72
🤖 Generated with Claude Code