From 006c365d25dd570dbc03e6664f3e313c64ccb1b3 Mon Sep 17 00:00:00 2001 From: Strycher Date: Sat, 18 Jul 2026 00:43:52 -0400 Subject: [PATCH] feat(#282): send a Giphy URL instead of g: for cross-client GIFs The picker emitted `g:`, a convention that exists only in this Flutter client lineage's parseGif (inherited from upstream, not a MeshCore protocol feature). A recipient on stock or any non-lineage client has no way to expand it, so a GIF arrived as opaque text (#223). encodeGif now emits `https://giphy.com/gifs/`, a form parseGif already matches, so: - stock clients receive a tappable URL they can actually open - Offband keeps rendering inline with no render-side change - legacy `g:` payloads still decode, back-compat unchanged Cost is ~41B vs ~20B against a ~160B payload. A full message carrying sender prefix + reply mention + URL measures ~79B, roughly half the cap. Checked, and did NOT change, the outbound structured-payload guard in prepareContactOutboundText/prepareChannelOutboundText: Smaz.encodeIfSmaller declines to compress a URL (base64 overhead exceeds the dictionary gain) and Cyr2Lat passes unmapped ASCII through, so a GIF URL survives intact without widening the guard. Both pinned as regression tests instead. Co-Authored-By: Claude Opus 4.8 --- lib/helpers/gif_helper.dart | 9 +++- .../gif_url_outbound_guard_test.dart | 44 +++++++++++++++++++ test/helpers/gif_url_encode_test.dart | 37 ++++++++++++++++ 3 files changed, 88 insertions(+), 2 deletions(-) create mode 100644 test/connector/gif_url_outbound_guard_test.dart create mode 100644 test/helpers/gif_url_encode_test.dart diff --git a/lib/helpers/gif_helper.dart b/lib/helpers/gif_helper.dart index ce2b3f3..f97ee3a 100644 --- a/lib/helpers/gif_helper.dart +++ b/lib/helpers/gif_helper.dart @@ -34,8 +34,13 @@ class GifHelper { return pageMatch?.group(1); } - /// Encode a GIF in a format that parseGif() can parse. + /// Encode a GIF as a Giphy page URL, a format parseGif() also parses. + /// + /// A real URL rather than the lineage-only `g:` token: recipients on + /// stock (or any non-lineage) MeshCore client can open it, where `g:` + /// is opaque text to them. parseGif already matches this form, so Offband + /// keeps rendering inline and legacy `g:` payloads still decode. (#282) static String encodeGif(String gifId) { - return 'g:$gifId'; + return 'https://giphy.com/gifs/$gifId'; } } diff --git a/test/connector/gif_url_outbound_guard_test.dart b/test/connector/gif_url_outbound_guard_test.dart new file mode 100644 index 0000000..86c6fef --- /dev/null +++ b/test/connector/gif_url_outbound_guard_test.dart @@ -0,0 +1,44 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/connector/meshcore_connector.dart'; +import 'package:meshcore_open/helpers/gif_helper.dart'; +import 'package:meshcore_open/helpers/smaz.dart'; +import 'package:meshcore_open/storage/prefs_manager.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +// #282: the GIF payload is now a URL. Outbound text prep skips Smaz/Cyr2Lat for +// "structured payloads" (it keyed on `g:`/`m:`/`V1|`). A transformed GIF URL +// would arrive as opaque `s:`-prefixed base64 on a stock client, which is worse +// than the raw text this change exists to fix. Pin that it survives intact. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const gifId = 'zaMiq1BvCdAVIiu3Mb'; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + PrefsManager.reset(); + await PrefsManager.initialize(); + }); + + test('Smaz leaves the GIF URL intact on a Smaz-enabled channel', () async { + final connector = MeshCoreConnector(); + await connector.setChannelSmazEnabled(0, true); + + final payload = GifHelper.encodeGif(gifId); + final prepared = connector.prepareChannelOutboundText(0, payload); + + expect(prepared, payload); + expect(prepared.startsWith('s:'), isFalse); + expect(GifHelper.parseGif(prepared), gifId); + }); + + test('Smaz declines to compress a GIF URL at all', () { + // Why the outbound `g:`/`m:`/`V1|` structured-payload guard did not need + // widening for #282: encodeIfSmaller only swaps in the `s:`+base64 form + // when it is genuinely smaller, and base64 overhead exceeds any dictionary + // gain on a URL. Pinned so a future dictionary change cannot silently start + // compressing GIF URLs into something a stock client cannot read. + final payload = GifHelper.encodeGif(gifId); + expect(Smaz.encodeIfSmaller(payload), payload); + }); +} diff --git a/test/helpers/gif_url_encode_test.dart b/test/helpers/gif_url_encode_test.dart new file mode 100644 index 0000000..1502735 --- /dev/null +++ b/test/helpers/gif_url_encode_test.dart @@ -0,0 +1,37 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:meshcore_open/helpers/gif_helper.dart'; + +// #282: the picker sends a real Giphy URL instead of the lineage-only +// `g:` token, so stock / non-lineage MeshCore clients can retrieve the +// GIF instead of seeing opaque text. Offband keeps rendering inline because +// parseGif already matches this URL form. +void main() { + const gifId = 'zaMiq1BvCdAVIiu3Mb'; + + group('encodeGif emits a cross-client retrievable Giphy URL', () { + test('emits the giphy.com page URL, not g:', () { + expect(GifHelper.encodeGif(gifId), 'https://giphy.com/gifs/$gifId'); + }); + + test('what it emits round-trips back to the id through parseGif', () { + expect(GifHelper.parseGif(GifHelper.encodeGif(gifId)), gifId); + }); + }); + + group('back-compat: legacy payloads still render', () { + test('still parses a legacy g: payload', () { + expect(GifHelper.parseGif('g:$gifId'), gifId); + }); + + test('still parses a legacy g: reply payload', () { + expect(GifHelper.parseGif('@[Some Node] g:$gifId'), gifId); + }); + + test('parses a reply that carries the new URL form', () { + expect( + GifHelper.parseGif('@[Some Node] https://giphy.com/gifs/$gifId'), + gifId, + ); + }); + }); +}