fix(mobile): match browser GIF timing in the iOS reaction sheet (#9437)

The two follow-ups from the pre-review of [#9432 GIF frame
timing](https://github.com/block/buzz/pull/9432), rebased onto main now
that it has landed.

**iOS reaction details sheet.** The sheet decodes custom emoji natively
(`NativeMessageImageLoader.decode`) and timed each frame as `max(0.02,
delay)`, so with [#9432 GIF frame
timing](https://github.com/block/buzz/pull/9432) a GIF with 0 or 1 cs
delays plays at 100 ms per frame in the timeline chip but 20 ms per
frame in the sheet opened from it. The decoder now shows frames of 10 ms
or less for 100 ms, as browsers and `MediaImageProvider` do, and keeps
longer frames as written. The 20 ms floor goes with it: browsers keep 11
to 19 ms frames as written too.

**Provider-level test.** The existing tests call
`withBrowserFrameTiming` directly, so removing the provider's call to it
left the suite green. A new test resolves a GIF through
`MediaImageProvider` with a mock HTTP client and pumps the scheduler's
app frames at explicit timestamps: the 10 ms first frame is still
showing at 50 ms and is replaced at 100 ms.

### Regressions

None expected. The sheet already summed the frame delays into one
`UIImage.animatedImage` duration, so only the per-frame value changes,
and only for frames of 10 ms or less, which ran at 20 ms before. Still
images and longer frames decode as before. The Dart test drives frame
callbacks with explicit timestamps rather than waiting on wall-clock
time.

### Validation

Added tests: `NativeMessageImageLoaderDecodeTests` in RunnerTests (a 0,
1, 2, 5 cs GIF decodes to a 0.27 s animation; a one-frame GIF stays
still) and `withBrowserFrameTiming is applied to the frames
MediaImageProvider plays` in `media_image_test.dart`, which fails when
the provider's call is removed (the second frame shows at 50 ms).

Full mobile suite: 4,780 tests passed. `flutter analyze`: no issues.
RunnerTests `NativeMessageImageLoaderDecodeTests` and
`NativeReactionDetailsTests`: all five pass on an iPhone 17 simulator
(Xcode build-for-testing of the Debug simulator app). No full-app run:
the change is one delay value in a decoder and is covered by the unit
tests above.

Signed-off-by: Tom Brow <tomb@squareup.com>
Co-authored-by: Tom Brow <tomb@squareup.com>
Co-authored-by: judge <7615ec4b7ddf99f0bb48e913297f99f8f772d289a1c9ef54e1d906dd55a93ba8@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
Tom Brow
2026-11-12 18:01:12 -08:00
committed by GitHub
parent 0bbcf3e7c2
commit bf3bdd0eae
3 changed files with 87 additions and 15 deletions

View File

@@ -574,7 +574,10 @@ final class NativeMessageImageLoader: NSObject, URLSessionDataDelegate {
let animation = (properties?[kCGImagePropertyGIFDictionary as String]
?? properties?[kCGImagePropertyPNGDictionary as String] ?? properties?["{WebP}"]) as? [String: Any]
let delay = (animation?["UnclampedDelayTime"] ?? animation?["DelayTime"]) as? Double ?? 0.1
duration += max(0.02, delay) * Double(step)
// Frames asking for 10 ms or less show for 100 ms, as in browsers and
// the Flutter timeline (MediaImageProvider), so the sheet plays an
// emoji at the same speed as the chip that opened it.
duration += (delay <= 0.01 ? 0.1 : delay) * Double(step)
}
guard let first = frames.first else { return nil }
return frames.count > 1 ? UIImage.animatedImage(with: frames, duration: duration) : first

View File

@@ -102,6 +102,35 @@ final class NativeMessageImageLoaderTests: XCTestCase {
}
}
final class NativeMessageImageLoaderDecodeTests: XCTestCase {
/// A 1x1 GIF with one frame per delay, in centiseconds.
private func gif(_ delays: [Int]) -> Data {
var bytes: [UInt8] = Array("GIF89a".utf8)
bytes += [1, 0, 1, 0, 0x80, 0, 0] // 1x1, two-colour global table
bytes += [0, 0, 0, 0xff, 0xff, 0xff]
for delay in delays {
bytes += [0x21, 0xf9, 4, 0, UInt8(delay & 0xff), UInt8(delay >> 8), 0, 0] // frame delay
bytes += [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0] // 1x1 image
bytes += [2, 2, 0x44, 1, 0] // one pixel
}
bytes.append(0x3b)
return Data(bytes)
}
func testShowsFramesOfTenMillisecondsOrLessForOneHundred() throws {
// Browsers and the Flutter timeline show 0 and 1 cs frames for 100 ms;
// longer frames keep their own delay.
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([0, 1, 2, 5])))
XCTAssertEqual(image.images?.count, 4)
XCTAssertEqual(image.duration, 0.1 + 0.1 + 0.02 + 0.05, accuracy: 0.001)
}
func testStillImageIsNotAnimated() throws {
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([5])))
XCTAssertNil(image.images)
}
}
private final class MessageImageProtocol: URLProtocol {
static var start: ((MessageImageProtocol) -> Void)?
override class func canInit(with request: URLRequest) -> Bool { true }

View File

@@ -22,6 +22,19 @@ final _pngBytes = base64Decode(
const _relayBase = 'https://relay.example.com';
const _mediaUrl = '$_relayBase/media/abc123.png';
// A 1x1 GIF with one frame per delay, in centiseconds.
Uint8List _gif(List<int> delays) => Uint8List.fromList([
...ascii.encode('GIF89a'),
1, 0, 1, 0, 0x80, 0, 0, // 1x1, two-colour global table
0, 0, 0, 0xff, 0xff, 0xff,
for (final delay in delays) ...[
0x21, 0xf9, 4, 0, delay & 0xff, delay >> 8, 0, 0, // frame delay
0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, // 1x1 image
2, 2, 0x44, 1, 0, // one pixel
],
0x3b,
]);
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
@@ -36,19 +49,6 @@ void main() {
});
group('withBrowserFrameTiming', () {
// A 1x1 GIF with one frame per delay, in centiseconds.
Uint8List gif(List<int> delays) => Uint8List.fromList([
...ascii.encode('GIF89a'),
1, 0, 1, 0, 0x80, 0, 0, // 1x1, two-colour global table
0, 0, 0, 0xff, 0xff, 0xff,
for (final delay in delays) ...[
0x21, 0xf9, 4, 0, delay & 0xff, delay >> 8, 0, 0, // frame delay
0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, // 1x1 image
2, 2, 0x44, 1, 0, // one pixel
],
0x3b,
]);
Future<List<Duration>> durations(Uint8List bytes) async {
final codec = withBrowserFrameTiming(
await ui.instantiateImageCodec(bytes),
@@ -64,7 +64,7 @@ void main() {
}
test('shows frames of 10 ms or less for 100 ms, like browsers', () async {
expect(await durations(gif([0, 1, 2, 5])), const [
expect(await durations(_gif([0, 1, 2, 5])), const [
Duration(milliseconds: 100),
Duration(milliseconds: 100),
Duration(milliseconds: 20),
@@ -77,6 +77,46 @@ void main() {
expect(withBrowserFrameTiming(codec), same(codec));
codec.dispose();
});
test('is applied to the frames MediaImageProvider plays', () async {
final binding = TestWidgetsFlutterBinding.instance;
final client = http_testing.MockClient(
(_) async => http.Response.bytes(_gif([1, 5]), 200),
);
final stream = MediaImageProvider(
url: '$_relayBase/media/fast.gif',
auth: _auth(),
client: client,
).resolve(ImageConfiguration.empty);
var frames = 0;
final listener = ImageStreamListener((image, _) {
image.dispose();
frames += 1;
});
stream.addListener(listener);
addTearDown(() => stream.removeListener(listener));
// The completer shows a frame on the app frame it scheduled, once the
// one it asks for has been on screen for that frame's duration.
Future<void> appFrame(Duration at) async {
final deadline = DateTime.now().add(const Duration(seconds: 10));
while (binding.transientCallbackCount == 0) {
if (DateTime.now().isAfter(deadline)) fail('no app frame scheduled');
await Future<void>.delayed(const Duration(milliseconds: 1));
}
binding.handleBeginFrame(at);
binding.handleDrawFrame();
}
const start = Duration(seconds: 1);
await appFrame(start);
expect(frames, 1);
// The first frame asks for 10 ms; it shows for 100 ms, not 50.
await appFrame(start + const Duration(milliseconds: 50));
expect(frames, 1);
await appFrame(start + const Duration(milliseconds: 100));
expect(frames, 2);
});
});
group('MediaGetAuthService mint-per-request', () {