feat(#626): emit the stock contact share URI

Adds Contact.toShareUri and the static Contact.buildShareUri, the inverse
of #625's parser. Emitting the reference-app format is what makes an
Offband card or QR importable by a stock, non-Offband user.

buildShareUri takes raw parts rather than a Contact so the local device
can share its OWN identity, which is a public key plus a node name and
never a Contact instance.

Spaces are percent-encoded rather than emitted as '+'. Both decode to a
space, and this matches Channel.toShareUri, which the codebase already
documents as round-tripping with the reference app's QR (#161).

Tests cover the emitted parameter shape, the companion default, and
round-tripping through fromShareUri for names with spaces, emoji and
reserved characters. A raw & or = in a name would otherwise truncate or
forge query parameters, so that case is pinned explicitly.

Epic #619.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
fix/652-release-prerelease-flag
Strycher 3 weeks ago
parent 007d0fb4f8
commit bbea912cb0

@ -260,6 +260,33 @@ class Contact {
}
}
/// Reference-app contact share URI for this contact, the inverse of
/// [fromShareUri]. This is what the stock app accepts, so emitting it is
/// what makes Offband cards and QRs importable by non-Offband users. (#626)
///
/// Note this shares only the identity. It carries no path and no advert, so
/// the receiving side gets an unverified stub exactly as we do.
String toShareUri() =>
buildShareUri(publicKeyHex: publicKeyHex, name: name, type: type);
/// Builds the reference-app contact share URI from raw parts.
///
/// Separate from [toShareUri] so the local device can share its OWN identity,
/// which is a public key and a node name rather than a [Contact]. (#626)
///
/// Spaces are percent-encoded rather than emitted as `+`. Both decode to a
/// space, and this matches [Channel.toShareUri], which is already documented
/// as round-tripping with the reference app's QR. (#161)
static String buildShareUri({
required String publicKeyHex,
required String name,
int type = advTypeChat,
}) =>
'meshcore://contact/add'
'?name=${Uri.encodeComponent(name)}'
'&public_key=$publicKeyHex'
'&type=$type';
/// Parses a contact from the reference-app share URI, or null if malformed.
///
/// `meshcore://contact/add?name=<url-encoded>&public_key=<64 hex>&type=<1-4>`

@ -1,3 +1,5 @@
import 'dart:typed_data';
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/connector/meshcore_protocol.dart';
import 'package:meshcore_open/models/contact.dart';
@ -159,4 +161,71 @@ void main() {
expect(Contact.isValidShareUri('meshcore://channel/add?name=x'), isFalse);
});
});
group('Contact share URI emit (#626)', () {
Contact stub(String name, int type) => Contact(
publicKey: hex2Uint8List(_key),
name: name,
type: type,
pathLength: -1,
path: Uint8List(0),
lastSeen: DateTime.fromMillisecondsSinceEpoch(0),
);
test('buildShareUri emits the documented parameter shape', () {
expect(
Contact.buildShareUri(publicKeyHex: _key, name: 'Bob', type: 2),
'meshcore://contact/add?name=Bob&public_key=$_key&type=2',
);
});
test('type defaults to companion when not supplied', () {
expect(
Contact.buildShareUri(publicKeyHex: _key, name: 'Bob'),
endsWith('&type=$advTypeChat'),
);
});
test('toShareUri round-trips through fromShareUri', () {
for (final name in [
'Bob',
'Two Words',
'DIRT WIZARD 🧙',
'amp&equals=hash#q',
'Ka8sbi',
]) {
final original = stub(name, advTypeRepeater);
final back = Contact.fromShareUri(original.toShareUri());
expect(back, isNotNull, reason: 'name "$name" should round-trip');
expect(back!.name, name);
expect(back.publicKeyHex, original.publicKeyHex);
expect(back.type, original.type);
}
});
test('reserved characters in a name are encoded, not emitted raw', () {
// A raw & or = would silently truncate or forge query parameters.
final uri = stub('a&b=c', advTypeChat).toShareUri();
expect(uri.contains('name=a&b=c'), isFalse);
expect(Contact.fromShareUri(uri)!.name, 'a&b=c');
});
test('every documented type survives a round-trip', () {
for (final t in [
advTypeChat,
advTypeRepeater,
advTypeRoom,
advTypeSensor,
]) {
expect(Contact.fromShareUri(stub('N', t).toShareUri())!.type, t);
}
});
test('what we emit is what we accept', () {
expect(
Contact.isValidShareUri(stub('N', advTypeChat).toShareUri()),
true,
);
});
});
}

Loading…
Cancel
Save

Powered by TurnKey Linux.