feat(#282): send a Giphy URL instead of g:<code> for cross-client GIFs

The picker emitted `g:<id>`, 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/<id>`, 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:<id>` 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 <noreply@anthropic.com>
pull/319/head
Strycher 4 days ago
parent 977af98069
commit 006c365d25

@ -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:<id>` token: recipients on
/// stock (or any non-lineage) MeshCore client can open it, where `g:<id>`
/// is opaque text to them. parseGif already matches this form, so Offband
/// keeps rendering inline and legacy `g:<id>` payloads still decode. (#282)
static String encodeGif(String gifId) {
return 'g:$gifId';
return 'https://giphy.com/gifs/$gifId';
}
}

@ -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);
});
}

@ -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:<code>` 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:<code>', () {
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:<code> payload', () {
expect(GifHelper.parseGif('g:$gifId'), gifId);
});
test('still parses a legacy g:<code> 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,
);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.