Skip to content

Commit bf3bdd0

Browse files
browsquare-tombjudge
authored
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](#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](#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 <[email protected]> Co-authored-by: Tom Brow <[email protected]> Co-authored-by: judge <7615ec4b7ddf99f0bb48e913297f99f8f772d289a1c9ef54e1d906dd55a93ba8@sprout-oss.stage.blox.sqprod.co>
1 parent 0bbcf3e commit bf3bdd0

3 files changed

Lines changed: 87 additions & 15 deletions

File tree

‎mobile/ios/Runner/NativeReactionDetails.swift‎

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

‎mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,35 @@ final class NativeMessageImageLoaderTests: XCTestCase {
102102
}
103103
}
104104

105+
final class NativeMessageImageLoaderDecodeTests: XCTestCase {
106+
/// A 1x1 GIF with one frame per delay, in centiseconds.
107+
private func gif(_ delays: [Int]) -> Data {
108+
var bytes: [UInt8] = Array("GIF89a".utf8)
109+
bytes += [1, 0, 1, 0, 0x80, 0, 0] // 1x1, two-colour global table
110+
bytes += [0, 0, 0, 0xff, 0xff, 0xff]
111+
for delay in delays {
112+
bytes += [0x21, 0xf9, 4, 0, UInt8(delay & 0xff), UInt8(delay >> 8), 0, 0] // frame delay
113+
bytes += [0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0] // 1x1 image
114+
bytes += [2, 2, 0x44, 1, 0] // one pixel
115+
}
116+
bytes.append(0x3b)
117+
return Data(bytes)
118+
}
119+
120+
func testShowsFramesOfTenMillisecondsOrLessForOneHundred() throws {
121+
// Browsers and the Flutter timeline show 0 and 1 cs frames for 100 ms;
122+
// longer frames keep their own delay.
123+
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([0, 1, 2, 5])))
124+
XCTAssertEqual(image.images?.count, 4)
125+
XCTAssertEqual(image.duration, 0.1 + 0.1 + 0.02 + 0.05, accuracy: 0.001)
126+
}
127+
128+
func testStillImageIsNotAnimated() throws {
129+
let image = try XCTUnwrap(NativeMessageImageLoader.decode(gif([5])))
130+
XCTAssertNil(image.images)
131+
}
132+
}
133+
105134
private final class MessageImageProtocol: URLProtocol {
106135
static var start: ((MessageImageProtocol) -> Void)?
107136
override class func canInit(with request: URLRequest) -> Bool { true }

‎mobile/test/shared/relay/media_image_test.dart‎

Lines changed: 54 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,19 @@ final _pngBytes = base64Decode(
2222
const _relayBase = 'https://relay.example.com';
2323
const _mediaUrl = '$_relayBase/media/abc123.png';
2424

25+
// A 1x1 GIF with one frame per delay, in centiseconds.
26+
Uint8List _gif(List<int> delays) => Uint8List.fromList([
27+
...ascii.encode('GIF89a'),
28+
1, 0, 1, 0, 0x80, 0, 0, // 1x1, two-colour global table
29+
0, 0, 0, 0xff, 0xff, 0xff,
30+
for (final delay in delays) ...[
31+
0x21, 0xf9, 4, 0, delay & 0xff, delay >> 8, 0, 0, // frame delay
32+
0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, // 1x1 image
33+
2, 2, 0x44, 1, 0, // one pixel
34+
],
35+
0x3b,
36+
]);
37+
2538
MediaGetAuthService _auth({String? nsec, DateTime Function()? now}) =>
2639
MediaGetAuthService(baseUrl: _relayBase, nsec: nsec, now: now);
2740

@@ -36,19 +49,6 @@ void main() {
3649
});
3750

3851
group('withBrowserFrameTiming', () {
39-
// A 1x1 GIF with one frame per delay, in centiseconds.
40-
Uint8List gif(List<int> delays) => Uint8List.fromList([
41-
...ascii.encode('GIF89a'),
42-
1, 0, 1, 0, 0x80, 0, 0, // 1x1, two-colour global table
43-
0, 0, 0, 0xff, 0xff, 0xff,
44-
for (final delay in delays) ...[
45-
0x21, 0xf9, 4, 0, delay & 0xff, delay >> 8, 0, 0, // frame delay
46-
0x2c, 0, 0, 0, 0, 1, 0, 1, 0, 0, // 1x1 image
47-
2, 2, 0x44, 1, 0, // one pixel
48-
],
49-
0x3b,
50-
]);
51-
5252
Future<List<Duration>> durations(Uint8List bytes) async {
5353
final codec = withBrowserFrameTiming(
5454
await ui.instantiateImageCodec(bytes),
@@ -64,7 +64,7 @@ void main() {
6464
}
6565

6666
test('shows frames of 10 ms or less for 100 ms, like browsers', () async {
67-
expect(await durations(gif([0, 1, 2, 5])), const [
67+
expect(await durations(_gif([0, 1, 2, 5])), const [
6868
Duration(milliseconds: 100),
6969
Duration(milliseconds: 100),
7070
Duration(milliseconds: 20),
@@ -77,6 +77,46 @@ void main() {
7777
expect(withBrowserFrameTiming(codec), same(codec));
7878
codec.dispose();
7979
});
80+
81+
test('is applied to the frames MediaImageProvider plays', () async {
82+
final binding = TestWidgetsFlutterBinding.instance;
83+
final client = http_testing.MockClient(
84+
(_) async => http.Response.bytes(_gif([1, 5]), 200),
85+
);
86+
final stream = MediaImageProvider(
87+
url: '$_relayBase/media/fast.gif',
88+
auth: _auth(),
89+
client: client,
90+
).resolve(ImageConfiguration.empty);
91+
var frames = 0;
92+
final listener = ImageStreamListener((image, _) {
93+
image.dispose();
94+
frames += 1;
95+
});
96+
stream.addListener(listener);
97+
addTearDown(() => stream.removeListener(listener));
98+
99+
// The completer shows a frame on the app frame it scheduled, once the
100+
// one it asks for has been on screen for that frame's duration.
101+
Future<void> appFrame(Duration at) async {
102+
final deadline = DateTime.now().add(const Duration(seconds: 10));
103+
while (binding.transientCallbackCount == 0) {
104+
if (DateTime.now().isAfter(deadline)) fail('no app frame scheduled');
105+
await Future<void>.delayed(const Duration(milliseconds: 1));
106+
}
107+
binding.handleBeginFrame(at);
108+
binding.handleDrawFrame();
109+
}
110+
111+
const start = Duration(seconds: 1);
112+
await appFrame(start);
113+
expect(frames, 1);
114+
// The first frame asks for 10 ms; it shows for 100 ms, not 50.
115+
await appFrame(start + const Duration(milliseconds: 50));
116+
expect(frames, 1);
117+
await appFrame(start + const Duration(milliseconds: 100));
118+
expect(frames, 2);
119+
});
80120
});
81121

82122
group('MediaGetAuthService mint-per-request', () {

0 commit comments

Comments
 (0)