mirror of
https://github.com/block/buzz.git
synced 2026-11-14 21:31:47 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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', () {
|
||||
|
||||
Reference in New Issue
Block a user