diff --git a/mobile/assets/fonts/InterVariable-Italic.ttf b/mobile/assets/fonts/InterVariable-Italic.ttf index 5b358cdd512..28df842895d 100644 Binary files a/mobile/assets/fonts/InterVariable-Italic.ttf and b/mobile/assets/fonts/InterVariable-Italic.ttf differ diff --git a/mobile/assets/fonts/InterVariable.ttf b/mobile/assets/fonts/InterVariable.ttf index 4ab79e0102b..dea05f17fa9 100644 Binary files a/mobile/assets/fonts/InterVariable.ttf and b/mobile/assets/fonts/InterVariable.ttf differ diff --git a/mobile/assets/fonts/README.md b/mobile/assets/fonts/README.md new file mode 100644 index 00000000000..723570d71af --- /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 00000000000..f0671b2f408 --- /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 00000000000..64119421fcc --- /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 00000000000..47fae5f188d --- /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 00000000000..51645c74da2 --- /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()