From 9462d8884a78147cf277988acb254af81f3df7f9 Mon Sep 17 00:00:00 2001 From: Tom Brow Date: Thu, 24 Sep 2026 07:30:30 -0700 Subject: [PATCH] Fix mobile heart and warning emoji with native font fallback (#7842) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Mobile heart and warning reactions and message text render as monochrome symbols because bundled Inter supplies U+2764 and U+26A0. Remove only those two character mappings from both regular and italic Inter fonts so Flutter uses native font fallback. No production Dart or Markdown parsing changes are needed. The reproducible font-processing script verifies that every other mapping and font table is unchanged, apart from the required checksum. Glyphs, metrics, language coverage and symbols such as © ® ™ ↑ ↓ − ∕ are preserved; the bundled Inter license is retained. Validated on an actual iPhone 17 Pro simulator running iOS 26.5. Reaction hearts and warnings remain centered within 0.33 logical pixels of the pill center. Native screenshot checks cover reactions and plain, emoji, explicit text-style, bold, italic, and enlarged emoji-only message hearts and warnings, plus pixel-identical preserved text samples. **Fallback behavior:** on iOS 26.5, plain ❤ / ⚠ and explicit text-style ❤︎ / ⚠︎ also render as color emoji. Native font metrics add one logical pixel to each of the three mixed-message lines in this fixture. Android visual behavior has not been validated. ### Reactions | Before | After | | --- | --- | | ![Monochrome hearts and warnings in reactions](https://raw.githubusercontent.com/block/buzz/cf60b78c79fbdc4ac314ba74f3fd27b76c823755/pr-7842--before-reactions.png) | ![Centered native color hearts and warnings in reactions](https://raw.githubusercontent.com/block/buzz/cf60b78c79fbdc4ac314ba74f3fd27b76c823755/pr-7842--after-reactions.png) | ### Message text | Before | After | | --- | --- | | ![Monochrome message hearts and warnings with preserved symbols](https://raw.githubusercontent.com/block/buzz/cf60b78c79fbdc4ac314ba74f3fd27b76c823755/pr-7842--before-messages.png) | ![Native color message hearts and warnings with preserved symbols](https://raw.githubusercontent.com/block/buzz/cf60b78c79fbdc4ac314ba74f3fd27b76c823755/pr-7842--after-messages.png) | Images are cropped native simulator screenshots of production widgets with synthetic content. Before uses the original bundled fonts; after uses the processed fonts. Added tests: the existing mobile CI test lane now checks both bundled fonts against the verified generated artifacts, preventing restoration of the old mappings or unintended font changes. Validation: the two asset tests, font-table verification, byte-identical regeneration, native screenshot regression checks, and Flutter analysis pass. The screenshot test fails against the original rendering. Pre-push checks, including the full mobile suite, pass. Full repository validation also passes. The enlarged emoji-only screenshot check passes and rejects the old normal-sized fixture. GitHub CI, Codex review, and the security review pass for commit 538167a788. Both prior Codex findings are addressed and resolved. --------- Signed-off-by: Tom Brow --- mobile/assets/fonts/InterVariable-Italic.ttf | Bin 910252 -> 910212 bytes mobile/assets/fonts/InterVariable.ttf | Bin 879708 -> 879668 bytes mobile/assets/fonts/README.md | 52 +++++++++++ mobile/scripts/prepare-inter.py | 78 ++++++++++++++++ .../test/shared/fonts/inter_assets_test.dart | 31 ++++++ mobile/test/visual/heart_sim_app.dart | 75 +++++++++++++++ .../test/visual/verify_heart_screenshots.py | 88 ++++++++++++++++++ 7 files changed, 324 insertions(+) create mode 100644 mobile/assets/fonts/README.md create mode 100644 mobile/scripts/prepare-inter.py create mode 100644 mobile/test/shared/fonts/inter_assets_test.dart create mode 100644 mobile/test/visual/heart_sim_app.dart create mode 100644 mobile/test/visual/verify_heart_screenshots.py diff --git a/mobile/assets/fonts/InterVariable-Italic.ttf b/mobile/assets/fonts/InterVariable-Italic.ttf index 5b358cdd512450ce20796304b5f3109cd430b8d6..28df842895d13963ba3be8e101f53577402eaa3c 100644 GIT binary patch delta 657 zcmZ2;+`Q$u`2=yt90mqWX7>R9;J0_TdoVDsDqvuWi*gSRb;_Su`GbLt;|v2soS8?M zW6%`C-ya#+(peZ7^aOo@{M(6Aj~LjdN=%H94?MuYz;GwAEU~D8t@Jbl+q5}AzDaU! zVnLKX$1(;6eV`>Nv(kX_j8gCNf$|rC^4rpLD%0*xy;^$?#h`P@a4LFEIwT z=^YbK*fVlW=3tapj9_5kKVOhvT%z=R#XbgRW(@|0I!6XZpaColP8O;Q7&dz_n(N7H zF))J#Re&@X1A~|r1B)0N0|Vn7ph|XzJDca}yD_q<3#*H(hi!gma0AZ1V04|4mEr#1 z`xho}FqvF`i)k9uI_5_#S6Dx>*|2%A#jrK7+W_r?19l5IlY!lc-HhD!3*;Q7T1 z402vIAU5Gm0?G5P;$6f0fcFQV7@r)U5}y`C4+8@qFld0J8ia<6ssP0tfY=2rJ6-V> ztFSGI0}+5J1M)5SfYySPLI59%zxj;#O!&raK0(%5I)wCdtnUj5`ou>)5=_ zmC0lBkzUp2uf6SGdl`Y435c12m<5PgftU@5*@2h?h&h3n3y8Uam}mRfUf%7Y0PH`R A&;S4c delta 691 zcmY+BZ%9*77{;G_@4RPs+dUG(q(RIiyd^cYWRoGejmrC>Kb9!b2Wd36FN&iq5c(rE zN`gUS+M*9p5E2n04HEiL6oG3IMMMdy8zELwK@cMSqxX8}q65GCp67YqbKc9ni7DTm zDc_~);0UluYVX+B`DJ!ehjjZ2#GXU#on4{+)Zj8?>vy2Xt9J(vj5PiH206AF@T}QG z`d!%d9`a})bIkiSW@O`S>%)m!FVsKGGMCZ?pTJ0}Z`E$u1#o6TLt&}A)xX_y#Lrh?T1?rAW0p-=^zBU zz=F{EzP7VjUGRC_en>0k2C6o|>4(MX1cV}WG8D7d8g>b`y6ak%R;_hwk?dmQ^nW_H z^{t?sDx&ba@UrkUeX}`zW=^~+-jx!jz6juQ0s{nHEQJQ zxH_TEtG~F5*YY~V9!qLU1keb!~aqdb>f=4a3TZivl|Qy@nIfe zjzP=<;)x7wZUzht+RuG~{M(6Aj~LjBBqm142OeNxV7QZ5mRQulR(hI&t#}TQZ<3sw zSkQBCVIKp7{vQU0RF*WLJfqaRe4zXVp!~Mf-8Qo9hg3!P!a1HyK&wAIRK) zKiS1}YW*Fi8B80PpRinG{laF)=EWAr*2HcHv8=sb`y3Bb|;|uKI}#8x7hEn zzhHmG@q|+jp@M;d6X;dWOHh0TBEt!!xtKth+W{iN1JzRxjX9Wamtre0eZb087ke7LH1-S&QV_lLNH=vC)ad=UW~3Ag|G=DKjKGz=S|- zGc}SANpC!^At+m73q>o?Gz3$V50J`K$x+UG+JcgG-(!8ZZr8(lEPvzB;*R`W`xi;g zm0Ts~@{i?8*5`eYD#y*IzIa!C3-0579;8_Ve3F0XCBDYj zEfsa|b_Jk*BtF$B8jecQ%BnQ<7>cddld7d8+intXZ7UiydYk4ZI<+fRUz^jOX)D@a z;S?>RRkRsBfbdjz!fnL5x}P+ RSO}G{5)IFjY1@l${{WHJoB#j- diff --git a/mobile/assets/fonts/README.md b/mobile/assets/fonts/README.md new file mode 100644 index 0000000000..723570d71a --- /dev/null +++ b/mobile/assets/fonts/README.md @@ -0,0 +1,52 @@ +# Inter heart and warning fallback + +The two Inter 4.001 variable fonts are derived from the previously bundled +fonts by removing only U+2764 and U+26A0 from their Unicode character maps. +Flutter can then choose a native font for heart and warning characters, +including emoji presentation +in reactions and message text. No glyphs, language coverage, other symbols, +font metrics, or variation tables are removed. The license remains in +`Inter-LICENSE.txt`. + +To reproduce from the repository root: + +```sh +mkdir -p /tmp/buzz-inter-source +for font in InterVariable.ttf InterVariable-Italic.ttf; do + git show ec7ea38f62ea917f15e85a678bc94f3bbee5bb64:mobile/assets/fonts/$font > /tmp/buzz-inter-source/$font +done +python3 -m venv /tmp/buzz-inter-tools +/tmp/buzz-inter-tools/bin/pip install fonttools==4.60.1 +/tmp/buzz-inter-tools/bin/python mobile/scripts/prepare-inter.py /tmp/buzz-inter-source +/tmp/buzz-inter-tools/bin/python mobile/scripts/prepare-inter.py /tmp/buzz-inter-source --check +``` + +The script checks pristine source hashes and verifies every character mapping +and every other font table. Only the `cmap` table and the font checksum may +change. Repeated generation from the same sources produces identical bytes. +©, ®, ™, arrows, math symbols, accents, and supported languages +keep their original Inter mappings. + +Use `test/visual/heart_sim_app.dart` on an actual iOS simulator to compare +reactions, plain/emoji/text-presentation hearts, bold/italic message text, +warning signs, and preserved symbols. Font fallback for plain and explicit +text-presentation hearts and warnings is platform-dependent; do not infer their appearance +from a macOS widget-test renderer. On iOS 26.5, all three heart and warning +presentations fall back to the color emoji, including explicit VS15. Message line metrics +can change with the fallback font. + +The native screenshot regression check (requires Pillow) covers both reaction +states, each heart and warning presentation, bold/italic text, and unchanged +symbols: + +```sh +python3 mobile/test/visual/verify_heart_screenshots.py before.png after.png +``` + +Use original 1206×2622 captures from the documented simulator and fixture for +this check. PR images may be cropped afterward for readability. + +`test/shared/fonts/inter_assets_test.dart` pins the verified generated font +artifacts in the existing Flutter test lane. Restoring either original font or +changing any other font data fails CI. When upgrading Inter, verify the +transformation and native rendering before updating the expected digests. diff --git a/mobile/scripts/prepare-inter.py b/mobile/scripts/prepare-inter.py new file mode 100644 index 0000000000..f0671b2f40 --- /dev/null +++ b/mobile/scripts/prepare-inter.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Remove Inter's heart and warning mappings for native emoji fallback. + +Requires fonttools==4.60.1. See assets/fonts/README.md for source and commands. +""" + +import argparse +import hashlib +from pathlib import Path + +from fontTools.ttLib import TTFont + + +SOURCES = { + "InterVariable.ttf": "4989b125924991b90d05b2d16e0e388c48f7d5bb8b30539bbf9c755278d0ccaf", + "InterVariable-Italic.ttf": "d6f1f6a172d9e588438db9f986fd5cfad7b30f644374080a8a9d4d91e344586f", +} +NATIVE_EMOJI = (0x2764, 0x26A0) + + +def verify(source: Path, output: Path) -> None: + with TTFont(source, lazy=True) as before, TTFont(output, lazy=True) as after: + if set(before.reader.keys()) != set(after.reader.keys()): + raise ValueError("Font tables changed") + for tag in before.reader.keys(): + original, modified = before.reader[tag], after.reader[tag] + if tag == "cmap": + continue + if tag == "head": + # The only allowed header change is the whole-font checksum. + original = original[:8] + original[12:] + modified = modified[:8] + modified[12:] + if original != modified: + raise ValueError(f"Unexpected change to {tag}") + old_tables, new_tables = before["cmap"].tables, after["cmap"].tables + if len(old_tables) != len(new_tables): + raise ValueError("Character-map table count changed") + for old, new in zip(old_tables, new_tables, strict=True): + identity = lambda t: (t.format, t.platformID, t.platEncID, t.language) + if identity(old) != identity(new): + raise ValueError("Character-map identity changed") + expected = dict(old.cmap) + if old.isUnicode(): + for codepoint in NATIVE_EMOJI: + del expected[codepoint] + if new.cmap != expected: + raise ValueError("Character mappings changed beyond U+2764 and U+26A0") + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("source_dir", type=Path) + parser.add_argument("--output-dir", type=Path, default=Path(__file__).resolve().parents[1] / "assets/fonts") + parser.add_argument("--check", action="store_true", help="Verify existing output without writing") + args = parser.parse_args() + for name, digest in SOURCES.items(): + source, output = args.source_dir / name, args.output_dir / name + if source.resolve() == output.resolve(): + raise ValueError("Keep pristine sources separate from generated fonts") + if hashlib.sha256(source.read_bytes()).hexdigest() != digest: + raise ValueError(f"Unexpected source font: {name}") + if not args.check: + with TTFont(source, lazy=True, recalcTimestamp=False) as font: + tables = [t for t in font["cmap"].tables if t.isUnicode()] + if not tables or any(codepoint not in t.cmap for t in tables for codepoint in NATIVE_EMOJI): + raise ValueError(f"Expected heart and warning mappings in {name}") + # FontTools shares dictionaries between equivalent subtables. + for cmap in {id(t.cmap): t.cmap for t in tables}.values(): + for codepoint in NATIVE_EMOJI: + del cmap[codepoint] + args.output_dir.mkdir(parents=True, exist_ok=True) + font.save(output, reorderTables=False) + verify(source, output) + print(f"{name}: only U+2764 and U+26A0 mappings removed; other mappings and glyph data unchanged") + + +if __name__ == "__main__": + main() diff --git a/mobile/test/shared/fonts/inter_assets_test.dart b/mobile/test/shared/fonts/inter_assets_test.dart new file mode 100644 index 0000000000..64119421fc --- /dev/null +++ b/mobile/test/shared/fonts/inter_assets_test.dart @@ -0,0 +1,31 @@ +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + // These are the reproducible outputs of scripts/prepare-inter.py. Pin the + // complete artifacts so CI rejects restored heart/warning mappings and any + // unintended changes to other glyphs, metrics, or language coverage. + // On a font update, first verify the transformation as documented in + // assets/fonts/README.md, then update these expected digests. + const expected = { + 'InterVariable.ttf': + 'c6540e8bbb50fac6ccdb0f0c01301bf195c95985c21fe9767cffe6963cc23afd', + 'InterVariable-Italic.ttf': + '74a6ff09b186744377e1ff68772e50754f5113079e1bd1abf2e4ab1942c1d9b6', + }; + + for (final entry in expected.entries) { + test('${entry.key} retains verified native emoji fallback coverage', () { + final bytes = File('assets/fonts/${entry.key}').readAsBytesSync(); + expect( + sha256.convert(bytes).toString(), + entry.value, + reason: + 'Bundled Inter must match the verified heart/warning fallback ' + 'artifact. See assets/fonts/README.md before updating this digest.', + ); + }); + } +} diff --git a/mobile/test/visual/heart_sim_app.dart b/mobile/test/visual/heart_sim_app.dart new file mode 100644 index 0000000000..47fae5f188 --- /dev/null +++ b/mobile/test/visual/heart_sim_app.dart @@ -0,0 +1,75 @@ +import 'package:buzz/features/channels/message_content.dart'; +import 'package:buzz/shared/custom_emoji/custom_emoji_provider.dart'; +import 'package:buzz/features/channels/reaction_row.dart'; +import 'package:buzz/features/channels/timeline_message.dart'; +import 'package:buzz/shared/theme/theme.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; + +// Native iOS visual fixture using production reaction widgets. +// flutter run -d -t test/visual/heart_sim_app.dart +// xcrun simctl io screenshot +void main() => runApp( + ProviderScope( + overrides: [customEmojiListProvider.overrideWithValue(const [])], + child: MaterialApp( + debugShowCheckedModeBanner: false, + theme: AppTheme.light(), + home: Scaffold( + body: SafeArea( + child: Padding( + padding: const EdgeInsets.all(24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const SizedBox(height: 24), + const Text('Hearts, warnings, and text'), + const SizedBox(height: 24), + const Text('Selected'), + _reactions(true), + const SizedBox(height: 24), + const Text('Unselected'), + _reactions(false), + const SizedBox(height: 24), + const Text('Message text'), + const SizedBox(height: 8), + _message('Plain ❤ Emoji ❤️ Text ❤︎'), + _message('**Bold ❤️ ⚠️** *Italic ❤️ ⚠️*'), + _message('Warnings: ⚠ ⚠️ ⚠︎'), + const SizedBox(height: 16), + const Text('Preserved symbols and text'), + const SizedBox(height: 8), + _message('© ® ™ ↑ ↓ − ∕ • … café naïve'), + _message('Ελληνικά · Кириллица · Tiếng Việt'), + const SizedBox(height: 16), + const Text('Emoji-only message'), + _message('❤️ ⚠️', scaleEmojiOnly: true), + ], + ), + ), + ), + ), + ), + ), +); + +Widget _reactions(bool selected) => ReactionRow( + messageId: 'heart-$selected', + reactions: [ + for (final emoji in ['❤️', '⚠️', '👍']) + TimelineReaction( + emoji: emoji, + count: 5, + reactedByCurrentUser: selected, + userPubkeys: const [], + ), + ], + onToggle: (_) {}, +); + +Widget _message(String content, {bool scaleEmojiOnly = false}) => + MessageContent( + content: content, + scaleEmojiOnly: scaleEmojiOnly, + channelNames: const {'general': 'general'}, + ); diff --git a/mobile/test/visual/verify_heart_screenshots.py b/mobile/test/visual/verify_heart_screenshots.py new file mode 100644 index 0000000000..51645c74da --- /dev/null +++ b/mobile/test/visual/verify_heart_screenshots.py @@ -0,0 +1,88 @@ +#!/usr/bin/env python3 +"""Check native captures of heart_sim_app.dart on iPhone 17 Pro, iOS 26.5. + +Usage: python verify_heart_screenshots.py before.png after.png +Requires Pillow. Inputs are original 1206x2622 simctl screenshots, not PR crops. +""" + +import sys + +from PIL import Image, ImageChops + + +def color_pixels(image, box, predicate): + left, top, right, bottom = box + return [ + (x, y) + for y in range(top, bottom) + for x in range(left, right) + if predicate(image.getpixel((x, y))) + ] + + +def is_red(color): + red, green, blue = color + return red > 140 and red > green * 1.5 and red > blue * 1.5 + + +def is_yellow(color): + red, green, blue = color + return red > 180 and green > 130 and blue < 90 + + +def main(): + before, after = [Image.open(path).convert("RGB") for path in sys.argv[1:]] + assert before.size == after.size == (1206, 2622), "Use the documented simulator and fixture" + regions = { + "selected reaction": (95, 522, 150, 606), + "unselected reaction": (95, 744, 150, 828), + "plain message heart": (175, 980, 225, 1035), + "emoji-presentation message heart": (375, 980, 435, 1035), + # Native iOS fallback currently renders VS15 as a color heart too. + "text-presentation message heart": (550, 980, 620, 1035), + "bold message heart": (170, 1040, 220, 1090), + "italic message heart": (430, 1040, 490, 1090), + "emoji-only message": (65, 1490, 200, 1650), + } + warnings = { + "selected warning reaction": (260, 522, 325, 606), + "unselected warning reaction": (260, 744, 325, 828), + "plain message warning": (275, 1090, 335, 1150), + "emoji-presentation message warning": (350, 1090, 415, 1150), + "text-presentation message warning": (430, 1090, 490, 1150), + "bold message warning": (230, 1040, 290, 1090), + "italic message warning": (490, 1040, 550, 1090), + "emoji-only warning": (205, 1490, 335, 1650), + } + for cases, predicate in ((regions, is_red), (warnings, is_yellow)): + for name, box in cases.items(): + assert not color_pixels(before, box, predicate), f"{name}: original must reproduce monochrome glyph" + pixels = color_pixels(after, box, predicate) + assert len(pixels) > 500, f"{name}: expected native color emoji" + if "emoji-only" in name: + # Production uses a separate 36sp path. Reject a fixture that + # accidentally renders normal-sized inline emoji or clips it. + left, right = min(x for x, _ in pixels), max(x for x, _ in pixels) + top, bottom = min(y for _, y in pixels), max(y for _, y in pixels) + assert len(pixels) > 4500, f"{name}: expected enlarged emoji" + assert 90 <= right - left <= 110 and 90 <= bottom - top <= 110 + assert box[0] < left < right < box[2] - 1 + assert box[1] < top < bottom < box[3] - 1 + if "reaction" in name: + center = (min(y for _, y in pixels) + max(y for _, y in pixels)) / 2 + pill_center = (box[1] + box[3] - 1) / 2 + assert abs(center - pill_center) <= 1.5, f"{name}: emoji is not centered" + print(f"PASS: {name}") + # The three preceding message lines gain one logical pixel each from the + # fallback font's metrics. Compare unchanged text after that translation. + for name, box in { + "preserved symbols and languages": (65, 1260, 1100, 1385), + }.items(): + shifted = (box[0], box[1] + 9, box[2], box[3] + 9) + difference = ImageChops.difference(before.crop(box), after.crop(shifted)) + assert difference.getbbox() is None, f"{name}: unexpected rendering change" + print(f"PASS: {name} is pixel-identical") + + +if __name__ == "__main__": + main()