Skip to content

[Upstream] Android: GIF emoji whose first frame is opaque (ShyHide) show one frame, and are blank in the Reactions sheet #615

Closed

Description

@tyne-claude-code

🤖 Written by Claude Code (AI agent), model claude-opus-5-5.

Reported by

Found by the maintainer, with @SeraphimOfTines

Original report

The Tyne Technical channel of the Temple of Seraphim community, 2026-11-12 around 19:17 Pacific.

Priority

P3 – Minor or cosmetic

Platforms

  • Android

Area

Media / uploads

Description

What happened?

The animated custom emoji :ShyHide: (Temple of Seraphim community, production) does not animate in the Android app. It is fine on web and desktop. Other animated emoji, such as the nodding one, animate on the phone as expected.

Steps to reproduce

  1. On Android, open a message with a :ShyHide: reaction.
  2. Watch the reaction chip.
  3. Long-press the reaction to open the Reactions sheet.

Expected result:

The emoji animates in the chip and shows in the sheet, as on web and desktop.

Actual result:

The chip is blank for a moment, then shows a single frame and stays on it. In the Reactions sheet the emoji is blank (only the count 1 and the name :ShyHide: show).

Other notes

Seraphim remembers using it on the phone earlier without problems; not confirmed. ShyHide was not one of the 79 emojis whose images were replaced in #526. The web and desktop emoji sync of #612 does not run on Android; the phone's own sync (#91) loads animated emoji through lib/core/media/fork_synced_emoji_image_cache.dart, so it is a suspect. Investigation follows in comments.

How often does it happen?

Every time

Activity

  1. tyne-claude-code commented on Nov 13, 2026

    @tyne-claude-code
    Author

    🤖 Written by Claude Code (AI agent), model claude-opus-5-5.

    Investigation

    Cause: a bug in Flutter's engine, fixed upstream in Flutter 3.50.0. We build with 3.47.7, and upstream Fluxer's CI pins 3.47.9, so upstream's app has it too.

    What the phone gets

    The phone asks for emojis/<id>.webp?animated=true&quality=lossless&size=96 (reaction chips) and size=18 (Reactions sheet). For a GIF emoji the media proxy answers with a GIF resized by FFmpeg (fluxer_media_proxy/src/native_shim/gif_filter.c: palettegen=reserve_transparent=1 / paletteuse, new palette per frame), not WebP.

    ShyHide's first frame is an almost fully opaque scene. The stored original has exactly one transparent pixel in frame 0; the resized copies have none, so their frame 0 declares no transparent colour. Later frames use "restore to background" disposal, which clears the canvas to transparent. That is valid GIF, and browsers show it correctly.

    Why Flutter fails on it

    engine/src/flutter/lib/ui/painting/multi_frame_codec.cc:65 (at our engine revision deb287481e) allocates every frame's bitmap with the alpha type of the image as a whole (generator_->GetInfo()). For a GIF, Skia takes that from frame 0, so here it is opaque. When a later frame has transparent pixels, Skia refuses to decode it into an opaque bitmap, and the engine throws Could not getPixels for frame N (:106-109). Fixed by flutter/flutter#193113 ("Decode animation frames with alpha after an opaque first frame", merged 2026-09-29, closing flutter/flutter#85831). Checked with the GitHub compare API: tags 3.50.0 and 3.51.0-0.1.pre contain it; 3.47.7, 3.47.9, 3.48 and 3.49 do not.

    On the phone, the animated load then fails and FluxerAnimatedEmojiImage shows its fallback, the still emoji (lib/shared/utils/emoji/emoji_image_cache.dart, fallback: _buildImage(context, animated: false)): the brief blank is the placeholder, then one frame. The Reactions sheet uses a plain CachedNetworkImage with no fallback, so it stays blank. Upstream's own image completer (cached_network_image_ce 4.13.2, multi_image_stream_completer.dart) ends the same way after showing frame 0. So the fork's emoji sync (#91, tyne-mobile 7196a95eb) is not the cause, and neither is the web/desktop sync (#612).

    What I ran

    All in this session, on copies fetched from production's public /media URLs (production database read only to find the emoji's id).

    • Decoded each file with ui.instantiateImageCodec under flutter test in the mobile builder image (Flutter 3.47.7):
      • The original GIF (112 px) decodes all 16 frames.
      • The resized copies fail. At 18 and 48 px, frames 5–15 fail (after the first "restore to background" frame, 4). At 64 and 96 px, frames 1–15 fail (frame 0 itself has that disposal).
      • Resized copies of three emoji that work (BlushyCry, NodNod, NodNodRainbow) decode fully: their frame 0 has transparent pixels.
    • Checked every frame's LZW data with a script of my own: all valid, right pixel counts.
    • Byte experiments on the 48 and 96 px files:
      • Setting the "transparent colour" flag on frame 0 alone makes every frame decode.
      • Turning every disposal into "keep" also makes them decode; turning only frame 0's into "keep" does not.
    • Frame 0 of each resized copy leaves palette index 255 unused: it is the transparent slot palettegen reserves. With frame 0 marked "transparent at index 255", Flutter decodes all 16 frames at all four sizes. Each frame matches, pixel for pixel, what Chromium's own GIF decoder (WebCodecs ImageDecoder, Playwright 1.50.1) composes from the unpatched file.

    Not checked

    • Seraphim's memory that it once animated on the phone. One possible explanation, untested: since upstream's fix(media-proxy): serve image originals when the queue is full fluxerapp/fluxer#3708 (2026-10-30) the proxy serves the original when its queue is full, and the original decodes fine. A phone that cached the original then would have animated.
    • Any device: everything above ran under flutter test on the host. The failure matches the screenshots, but nothing has run on a phone yet.

    Fix options (not decided yet)

    1. App, in the fork's own emoji cache (lib/core/media/fork_synced_emoji_image_cache.dart, _loadCodecs): before decoding an animated GIF whose frame 0 declares no transparency, mark frame 0 transparent at an index it does not use (found by decoding frame 0). Fork-owned code; it also fixes files already in phones' disk caches; to be dropped once upstream moves to Flutter 3.50+. Covers custom emoji everywhere they animate, but not GIF attachments or avatars. Ships with the next APK and iOS build.
    2. Media proxy: the same patch on the GIFs it resizes. It covers every client after a deploy, but needs a hook in upstream's gif_transform.c. Cached resizes would have to be cleared too, and it does not cover originals served as they are.
    3. Wait for upstream to move to Flutter 3.50+.
  2. changed the title [-]Android: the animated ShyHide emoji shows one frame, and is blank in the Reactions sheet[/-] [+][Upstream] Android: GIF emoji whose first frame is opaque (ShyHide) show one frame, and are blank in the Reactions sheet[/+] on Nov 13, 2026
  3. tyne-claude-code commented on Nov 13, 2026

    @tyne-claude-code
    Author

    🤖 Written by Claude Code (AI agent), model claude-opus-5-5.

    Moving the fork to Flutter 3.50 ourselves

    Upstream's CI used "latest stable" with no pin until Flutter 3.50.0 reached the stable channel (2026-11-11 19:05 UTC, from Flutter's release list). Its runs on 3.50.0 then failed. At 19:51 UTC upstream pinned 3.47.9 in all 18 workflows (05bd21f00, "fix(ci): pin flutter version").

    What failed on 3.50.0, from upstream's run 43088521862 (log read, nothing run here):

    • package-tests (fluxer_fcm): pub get fails. Flutter 3.50's flutter_test pins test_api 0.7.14, and the package's lint_hard ^7.1.2 plus test ^1.31.0 cannot resolve against it (pub suggests lint_hard ^8.0.1).
    • test: 4 failures, 3 in test/core/providers/fluxer_sfx_provider_test.dart and 1 in test/features/ui/page_sheet/fluxer_page_sheet_overscroll_test.dart. Upstream's later runs on 3.47.9 pass (for example Dart analyze on 2026-11-12 23:46 UTC), so these are probably due to 3.50; I have not reproduced them.

    So upstream has seen 3.50 break things and chose to stay on 3.47.9 for now. If the fork moved ahead on its own, upstream's code would be built and tested every night on a Flutter upstream does not test on. Our fixes to it (a package's pubspec, upstream tests, pubspec.lock, the pinned workflow lines) would sit inside upstream's files and conflict when upstream makes its own move.

  4. tyne-claude-code commented on Nov 13, 2026

    @tyne-claude-code
    Author

    🤖 Written by Claude Code (AI agent), model claude-opus-5-5.

    Fix in progress (not committed yet)

    Chiara chose the app-side workaround, since upstream deliberately stays on Flutter 3.47.9 for now.

    Change (tyne-mobile, uncommitted on cc1d8fa24):

    • New lib/core/media/fork_gif_first_frame_alpha.dart. Before an animated GIF emoji is decoded, it checks frame 0. If frame 0 covers the whole image and declares no transparent colour, the patch marks it transparent at the highest palette index frame 0 does not draw (found by following frame 0's LZW codes). If frame 0 has no graphic control extension, it inserts one. Anything it cannot read is left as it is.
    • One call in the fork's own fork_synced_emoji_image_cache.dart (_loadCodecs), just before decoding.
    • A sentence in ARCHITECTURE.md.
    • Removal is tracked in Mobile: remove the GIF first-frame workaround once every build uses Flutter 3.50+ #617.

    Tests added:

    • test/core/media/fork_gif_first_frame_alpha_test.dart covers:
      • the byte patch, including inserting a control extension and choosing the colour;
      • the cases left alone: already transparent, one frame, partial frame 0, every colour used, GIF87a, invalid LZW, not a GIF;
      • cut-off files, which are read without throwing;
      • two real decodes. "The engine still needs it" shows that Flutter 3.47.7 fails on the unpatched file; it fails on a fixed Flutter and says what to delete. The other checks that every frame decodes after the patch, with the expected pixels.
    • In fork_synced_emoji_image_cache_test.dart, one test that the cache passes the patched bytes to the decoder.
    • Test GIFs are built in code (fork_gif_test_support.dart); no emoji art is in the repository.

    Checks run (builder image, Flutter 3.47.7):

    • The full nightly gate passed: markdown packages, SDK round trips, Dart fork lint (format and analyser), fork string lint, flutter test (9,399 passed, 33 skipped, 0 failed) and the Kotlin unit tests.
    • Earlier, on ShyHide's real files from production: with the patch, Flutter's frames match what Chromium shows for the unpatched files, pixel for pixel, at 18, 48, 64 and 96 px.

    On a device: test build 1.2.21-dev.615.1+201 (that tree) was installed on Chiara's phone over wireless debugging on 2026-11-12 at 20:13 Pacific. Not yet confirmed on the device.

  5. tyne-claude-code commented on Nov 13, 2026

    @tyne-claude-code
    Author

    🤖 Written by Claude Code (AI agent), model claude-opus-5-5.

    Resolution

    Landed in tyne-mobile d85fab81c ("fix(emoji): animate GIF emoji whose first frame is opaque"), pushed to tyne on 2026-11-12.

    What changes for users: on Android and iOS, animated GIF emoji whose first frame is opaque, such as ShyHide, now animate in messages, reaction chips and the picker, and show in the Reactions sheet instead of a blank. No other emoji look different (the patch changes no pixel), and copies already in a phone's cache are fixed too. GIF attachments and avatars are not covered: they use upstream's image path.

    How it was checked:

    • The full nightly gate passed on the change (Flutter 3.47.7): fork format and analysis, fork string lint, 9,399 Flutter tests with 33 skipped and none failed, and the Kotlin unit tests.
    • The media tests passed again after a final comment edit.
    • Chiara confirmed the fix on her phone with test build 1.2.21-dev.615.1+201.

    What is still needed before users have it:

    • An APK release above code 201.
    • An iOS build.

    Follow-ups:

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions