You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Upstream] Android: GIF emoji whose first frame is opaque (ShyHide) show one frame, and are blank in the Reactions sheet #615
🤖 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
On Android, open a message with a :ShyHide: reaction.
Watch the reaction chip.
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.
🤖 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)
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.
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.
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
🤖 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.
🤖 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.
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.
🤖 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.
Activity
tyne-claude-code commentedon Nov 13, 2026
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) andsize=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 revisiondeb287481e) 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 throwsCould 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
FluxerAnimatedEmojiImageshows 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 plainCachedNetworkImagewith no fallback, so it stays blank. Upstream's own image completer (cached_network_image_ce4.13.2,multi_image_stream_completer.dart) ends the same way after showing frame 0. So the fork's emoji sync (#91, tyne-mobile7196a95eb) 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
/mediaURLs (production database read only to find the emoji's id).ui.instantiateImageCodecunderflutter testin the mobile builder image (Flutter 3.47.7):palettegenreserves. 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 (WebCodecsImageDecoder, Playwright 1.50.1) composes from the unpatched file.Not checked
flutter teston the host. The failure matches the screenshots, but nothing has run on a phone yet.Fix options (not decided yet)
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.gif_transform.c. Cached resizes would have to be cleared too, and it does not cover originals served as they are.[-]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[/+]tyne-claude-code commentedon Nov 13, 2026
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 getfails. Flutter 3.50'sflutter_testpinstest_api0.7.14, and the package'slint_hard ^7.1.2plustest ^1.31.0cannot resolve against it (pub suggestslint_hard ^8.0.1).test: 4 failures, 3 intest/core/providers/fluxer_sfx_provider_test.dartand 1 intest/features/ui/page_sheet/fluxer_page_sheet_overscroll_test.dart. Upstream's later runs on 3.47.9 pass (for exampleDart analyzeon 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.tyne-claude-code commentedon Nov 13, 2026
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):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.fork_synced_emoji_image_cache.dart(_loadCodecs), just before decoding.ARCHITECTURE.md.Tests added:
test/core/media/fork_gif_first_frame_alpha_test.dartcovers:fork_synced_emoji_image_cache_test.dart, one test that the cache passes the patched bytes to the decoder.fork_gif_test_support.dart); no emoji art is in the repository.Checks run (builder image, Flutter 3.47.7):
flutter test(9,399 passed, 33 skipped, 0 failed) and the Kotlin unit tests.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.tyne-claude-code commentedon Nov 13, 2026
Resolution
Landed in tyne-mobile
d85fab81c("fix(emoji): animate GIF emoji whose first frame is opaque"), pushed totyneon 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:
1.2.21-dev.615.1+201.What is still needed before users have it:
Follow-ups:
perf(images): animated avatars, icons and banners ask for one frame p…
fix(emoji): animate GIF emoji whose first frame is opaque
perf(images): animated avatars, icons and banners ask for one frame p…
fix(emoji): animate GIF emoji whose first frame is opaque
perf(images): animated avatars, icons and banners ask for one frame p…
fix(emoji): animate GIF emoji whose first frame is opaque
perf(images): animated avatars, icons and banners ask for one frame p…
fix(emoji): animate GIF emoji whose first frame is opaque
perf(images): animated avatars, icons and banners ask for one frame p…