feat(#163): share a channel as a scannable QR (reference-app format)

Channel.toShareUri/fromShareUri/isValidShareUri encode the reference
MeshCore app's format: meshcore://channel/add?name=<url-encoded>&secret=<hex32>.
A "Share QR Code" action in the channel menu shows the QR via the existing
QrCodeShareDialog, so it round-trips with the reference app.

Encode/decode unit-tested against both real reference examples (#test, #echo).
Scan-to-add follows.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
pull/201/head
Strycher 3 weeks ago
parent 497931955f
commit 01207f5019

@ -1982,6 +1982,16 @@
} }
} }
}, },
"channels_shareQr": "Share QR Code",
"channels_shareQrTitle": "Share Channel",
"channels_shareQrInstructions": "Scan to add \"{name}\".",
"@channels_shareQrInstructions": {
"placeholders": {
"name": {
"type": "String"
}
}
},
"community_hashtagPrivacyHint": "Community hashtag channels are only joinable by members of the community", "community_hashtagPrivacyHint": "Community hashtag channels are only joinable by members of the community",
"community_invalidQrCode": "Invalid community QR code", "community_invalidQrCode": "Invalid community QR code",
"community_alreadyMember": "Already a Member", "community_alreadyMember": "Already a Member",

@ -6356,6 +6356,24 @@ abstract class AppLocalizations {
/// **'Scan this QR code to join \"{name}\"'** /// **'Scan this QR code to join \"{name}\"'**
String community_qrInstructions(String name); String community_qrInstructions(String name);
/// No description provided for @channels_shareQr.
///
/// In en, this message translates to:
/// **'Share QR Code'**
String get channels_shareQr;
/// No description provided for @channels_shareQrTitle.
///
/// In en, this message translates to:
/// **'Share Channel'**
String get channels_shareQrTitle;
/// No description provided for @channels_shareQrInstructions.
///
/// In en, this message translates to:
/// **'Scan to add \"{name}\".'**
String channels_shareQrInstructions(String name);
/// No description provided for @community_hashtagPrivacyHint. /// No description provided for @community_hashtagPrivacyHint.
/// ///
/// In en, this message translates to: /// In en, this message translates to:

@ -3677,6 +3677,17 @@ class AppLocalizationsBg extends AppLocalizations {
return 'Сканирайте този QR код, за да се присъедините към $name.'; return 'Сканирайте този QR код, за да се присъедините към $name.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Хаштаг каналите на общността са достъпни само за членове на общността'; 'Хаштаг каналите на общността са достъпни само за членове на общността';

@ -3684,6 +3684,17 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Scannen Sie diesen QR-Code, um sich \"$name\" anzuschließen.'; return 'Scannen Sie diesen QR-Code, um sich \"$name\" anzuschließen.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Community-Hashtag-Kanäle können nur von Mitgliedern der Community betreten werden'; 'Community-Hashtag-Kanäle können nur von Mitgliedern der Community betreten werden';

@ -3613,6 +3613,17 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Scan this QR code to join \"$name\"'; return 'Scan this QR code to join \"$name\"';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Community hashtag channels are only joinable by members of the community'; 'Community hashtag channels are only joinable by members of the community';

@ -3672,6 +3672,17 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Escanear este código QR para unirte a $name'; return 'Escanear este código QR para unirte a $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Los canales de hashtag de la comunidad solo son accesibles para los miembros de la comunidad'; 'Los canales de hashtag de la comunidad solo son accesibles para los miembros de la comunidad';

@ -3695,6 +3695,17 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Scanner ce QR code pour rejoindre $name'; return 'Scanner ce QR code pour rejoindre $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Les canaux hashtag de la communauté ne sont accessibles qu\'aux membres de la communauté'; 'Les canaux hashtag de la communauté ne sont accessibles qu\'aux membres de la communauté';

@ -3688,6 +3688,17 @@ class AppLocalizationsHu extends AppLocalizations {
return 'Scanned this QR-kódot, hogy csatlakozhat a $name csoporthoz.'; return 'Scanned this QR-kódot, hogy csatlakozhat a $name csoporthoz.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'A közösségi hashtagekhez tartozó csatornák csak a közösség tagjai számára érhetők el.'; 'A közösségi hashtagekhez tartozó csatornák csak a közösség tagjai számára érhetők el.';

@ -3677,6 +3677,17 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Scansiona questo codice QR per unirti a $name'; return 'Scansiona questo codice QR per unirti a $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'I canali hashtag della community sono accessibili solo ai membri della community'; 'I canali hashtag della community sono accessibili solo ai membri della community';

@ -3490,6 +3490,17 @@ class AppLocalizationsJa extends AppLocalizations {
return 'このQRコードをスキャンして、$nameに参加してください。'; return 'このQRコードをスキャンして、$nameに参加してください。';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'コミュニティハッシュタグのチャンネルは、コミュニティのメンバーのみが参加できます。'; 'コミュニティハッシュタグのチャンネルは、コミュニティのメンバーのみが参加できます。';

@ -3492,6 +3492,17 @@ class AppLocalizationsKo extends AppLocalizations {
return '이 QR 코드를 스캔하여 \"$name\"에 가입하세요.'; return '이 QR 코드를 스캔하여 \"$name\"에 가입하세요.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'커뮤니티 해시태그 채널은 커뮤니티 구성원만 가입할 수 있습니다.'; '커뮤니티 해시태그 채널은 커뮤니티 구성원만 가입할 수 있습니다.';

@ -3658,6 +3658,17 @@ class AppLocalizationsNl extends AppLocalizations {
return 'Scan deze QR-code om je aan te sluiten bij $name'; return 'Scan deze QR-code om je aan te sluiten bij $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Community hashtag-kanalen zijn alleen toegankelijk voor leden van de community'; 'Community hashtag-kanalen zijn alleen toegankelijk voor leden van de community';

@ -3690,6 +3690,17 @@ class AppLocalizationsPl extends AppLocalizations {
return 'Skanuj ten kod QR, aby dołączyć $name'; return 'Skanuj ten kod QR, aby dołączyć $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Kanały hashtagowe społeczności są dostępne tylko dla członków społeczności'; 'Kanały hashtagowe społeczności są dostępne tylko dla członków społeczności';

@ -3671,6 +3671,17 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Escanear este código QR para juntar-se a $name'; return 'Escanear este código QR para juntar-se a $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Os canais de hashtag da comunidade só podem ser acessados por membros da comunidade'; 'Os canais de hashtag da comunidade só podem ser acessados por membros da comunidade';

@ -3679,6 +3679,17 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Отсканируйте этот QR-код, чтобы присоединиться к \"$name\"'; return 'Отсканируйте этот QR-код, чтобы присоединиться к \"$name\"';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Хэштег-каналы сообщества доступны только его участникам'; 'Хэштег-каналы сообщества доступны только его участникам';

@ -3657,6 +3657,17 @@ class AppLocalizationsSk extends AppLocalizations {
return 'Skenejte tento QR kód, aby ste sa pripojili k $name.'; return 'Skenejte tento QR kód, aby ste sa pripojili k $name.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Hashtagové kanály komunity sú prístupné len členom komunity'; 'Hashtagové kanály komunity sú prístupné len členom komunity';

@ -3652,6 +3652,17 @@ class AppLocalizationsSl extends AppLocalizations {
return 'Skenirajte to QR kodo za vključitev $name.'; return 'Skenirajte to QR kodo za vključitev $name.';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Hashtag kanali skupnosti so dostopni samo članom skupnosti'; 'Hashtag kanali skupnosti so dostopni samo članom skupnosti';

@ -3632,6 +3632,17 @@ class AppLocalizationsSv extends AppLocalizations {
return 'Skanna denna QR-kod för att gå med i \"$name\"'; return 'Skanna denna QR-kod för att gå med i \"$name\"';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Community-hashtagkanaler kan endast nås av medlemmar i communityn'; 'Community-hashtagkanaler kan endast nås av medlemmar i communityn';

@ -3675,6 +3675,17 @@ class AppLocalizationsUk extends AppLocalizations {
return 'Відскануйте цей QR-код, щоб приєднатись до $name'; return 'Відскануйте цей QR-код, щоб приєднатись до $name';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => String get community_hashtagPrivacyHint =>
'Хештег-канали спільноти доступні лише членам спільноти'; 'Хештег-канали спільноти доступні лише членам спільноти';

@ -3395,6 +3395,17 @@ class AppLocalizationsZh extends AppLocalizations {
return '扫描此二维码加入 \"$name\"'; return '扫描此二维码加入 \"$name\"';
} }
@override
String get channels_shareQr => 'Share QR Code';
@override
String get channels_shareQrTitle => 'Share Channel';
@override
String channels_shareQrInstructions(String name) {
return 'Scan to add \"$name\".';
}
@override @override
String get community_hashtagPrivacyHint => '仅社区成员可加入社区标签频道。'; String get community_hashtagPrivacyHint => '仅社区成员可加入社区标签频道。';

@ -68,6 +68,32 @@ class Channel {
return bytes; return bytes;
} }
/// Reference-app channel share URI:
/// `meshcore://channel/add?name=<url-encoded>&secret=<32-hex PSK>`.
/// Round-trips with the reference MeshCore app's channel QR. (#161)
String toShareUri() =>
'meshcore://channel/add?name=${Uri.encodeComponent(name)}&secret=$pskHex';
/// Parses a channel from a reference-app share URI, or null if malformed.
/// [index] is the local slot the caller assigns on add. (#161)
static Channel? fromShareUri(String uri, {int index = 0}) {
final parsed = Uri.tryParse(uri.trim());
if (parsed == null || parsed.scheme != 'meshcore') return null;
if (parsed.host != 'channel') return null;
if (parsed.path.replaceAll('/', '') != 'add') return null;
final name = parsed.queryParameters['name'];
final secret = parsed.queryParameters['secret'];
if (name == null || secret == null) return null;
try {
return Channel(index: index, name: name, psk: parsePskHex(secret));
} catch (_) {
return null;
}
}
/// True if [uri] is a valid channel share URI. (#161)
static bool isValidShareUri(String uri) => fromShareUri(uri) != null;
/// Derive PSK from hashtag name using SHA256. /// Derive PSK from hashtag name using SHA256.
/// The hashtag is normalized to include '#' prefix. /// The hashtag is normalized to include '#' prefix.
/// Returns first 16 bytes of SHA256 hash as PSK. /// Returns first 16 bytes of SHA256 hash as PSK.

@ -519,6 +519,17 @@ class _ChannelsScreenState extends State<ChannelsScreen>
} }
}, },
), ),
ListTile(
leading: const Icon(Icons.qr_code_2),
title: Text(context.l10n.channels_shareQr),
onTap: () async {
Navigator.pop(sheetContext);
await Future.delayed(const Duration(milliseconds: 100));
if (parentContext.mounted) {
_showChannelQrDialog(parentContext, channel);
}
},
),
ListTile( ListTile(
leading: Icon( leading: Icon(
isMuted isMuted
@ -1764,6 +1775,20 @@ class _ChannelsScreenState extends State<ChannelsScreen>
); );
} }
void _showChannelQrDialog(BuildContext context, Channel channel) {
QrCodeShareDialog.show(
context: context,
data: channel.toShareUri(),
title: context.l10n.channels_shareQrTitle,
instructions: context.l10n.channels_shareQrInstructions(channel.name),
embeddedImage: Image.asset(
'assets/images/mesh-icon.png',
width: 40,
height: 40,
),
);
}
void _confirmLeaveCommunity(BuildContext context, Community community) { void _confirmLeaveCommunity(BuildContext context, Community community) {
final connector = context.read<MeshCoreConnector>(); final connector = context.read<MeshCoreConnector>();

@ -0,0 +1,62 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:meshcore_open/models/channel.dart';
void main() {
group('Channel share URI (#161)', () {
test('toShareUri matches the reference-app format', () {
final ch = Channel(
index: 0,
name: '#test',
psk: Channel.parsePskHex('9cd8fcf22a47333b591d96a2b848b73f'),
);
expect(
ch.toShareUri(),
'meshcore://channel/add?name=%23test&secret=9cd8fcf22a47333b591d96a2b848b73f',
);
});
test('fromShareUri parses a real reference example', () {
final ch = Channel.fromShareUri(
'meshcore://channel/add?name=%23echo&secret=5d25cf40b1f5b4a7eb0cf9703634a948',
);
expect(ch, isNotNull);
expect(ch!.name, '#echo');
expect(ch.pskHex, '5d25cf40b1f5b4a7eb0cf9703634a948');
});
test('round-trips name (incl. spaces) + psk', () {
final ch = Channel(
index: 3,
name: 'Private Ops',
psk: Channel.parsePskHex('0011223344556677889900aabbccddee'),
);
final back = Channel.fromShareUri(ch.toShareUri());
expect(back, isNotNull);
expect(back!.name, ch.name);
expect(back.pskHex, ch.pskHex);
});
test('rejects malformed / wrong-scheme URIs', () {
// wrong scheme
expect(
Channel.fromShareUri(
'https://channel/add?name=x&secret=9cd8fcf22a47333b591d96a2b848b73f',
),
isNull,
);
// wrong host
expect(
Channel.fromShareUri('meshcore://contact/add?name=x&secret=00'),
isNull,
);
// missing secret
expect(Channel.fromShareUri('meshcore://channel/add?name=x'), isNull);
// secret not 16 bytes
expect(
Channel.fromShareUri('meshcore://channel/add?name=x&secret=abcd'),
isNull,
);
expect(Channel.isValidShareUri('not a uri at all'), isFalse);
});
});
}
Loading…
Cancel
Save

Powered by TurnKey Linux.