diff --git a/mobile/ios/Runner/NativeReactionDetails.swift b/mobile/ios/Runner/NativeReactionDetails.swift index cf052327b6..831ad98f7b 100644 --- a/mobile/ios/Runner/NativeReactionDetails.swift +++ b/mobile/ios/Runner/NativeReactionDetails.swift @@ -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 diff --git a/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift b/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift index 95de80e2d9..378a164d2d 100644 --- a/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift +++ b/mobile/ios/RunnerTests/NativeMessageImageLoaderTests.swift @@ -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 } diff --git a/mobile/test/shared/relay/media_image_test.dart b/mobile/test/shared/relay/media_image_test.dart index dc97a293bb..dce78b8dbd 100644 --- a/mobile/test/shared/relay/media_image_test.dart +++ b/mobile/test/shared/relay/media_image_test.dart @@ -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 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 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> 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 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.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', () {