diff --git a/lib/models/contact.dart b/lib/models/contact.dart index 09981a1..de338e1 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -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=&public_key=<64 hex>&type=<1-4>` diff --git a/test/models/contact_share_uri_test.dart b/test/models/contact_share_uri_test.dart index 05ca25c..670a9f9 100644 --- a/test/models/contact_share_uri_test.dart +++ b/test/models/contact_share_uri_test.dart @@ -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, + ); + }); + }); }