diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 1a4838d..e09cf2f 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -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_invalidQrCode": "Invalid community QR code", "community_alreadyMember": "Already a Member", diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 885eff6..490158a 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -6356,6 +6356,24 @@ abstract class AppLocalizations { /// **'Scan this QR code to join \"{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. /// /// In en, this message translates to: diff --git a/lib/l10n/app_localizations_bg.dart b/lib/l10n/app_localizations_bg.dart index e7dcab1..b8f5799 100644 --- a/lib/l10n/app_localizations_bg.dart +++ b/lib/l10n/app_localizations_bg.dart @@ -3677,6 +3677,17 @@ class AppLocalizationsBg extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Хаштаг каналите на общността са достъпни само за членове на общността'; diff --git a/lib/l10n/app_localizations_de.dart b/lib/l10n/app_localizations_de.dart index 14c495e..206ecdb 100644 --- a/lib/l10n/app_localizations_de.dart +++ b/lib/l10n/app_localizations_de.dart @@ -3684,6 +3684,17 @@ class AppLocalizationsDe extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Community-Hashtag-Kanäle können nur von Mitgliedern der Community betreten werden'; diff --git a/lib/l10n/app_localizations_en.dart b/lib/l10n/app_localizations_en.dart index fdb19cf..ed87392 100644 --- a/lib/l10n/app_localizations_en.dart +++ b/lib/l10n/app_localizations_en.dart @@ -3613,6 +3613,17 @@ class AppLocalizationsEn extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Community hashtag channels are only joinable by members of the community'; diff --git a/lib/l10n/app_localizations_es.dart b/lib/l10n/app_localizations_es.dart index 8141fce..ed1c8b7 100644 --- a/lib/l10n/app_localizations_es.dart +++ b/lib/l10n/app_localizations_es.dart @@ -3672,6 +3672,17 @@ class AppLocalizationsEs extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Los canales de hashtag de la comunidad solo son accesibles para los miembros de la comunidad'; diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index 5d25d1d..9d64cd6 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -3695,6 +3695,17 @@ class AppLocalizationsFr extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Les canaux hashtag de la communauté ne sont accessibles qu\'aux membres de la communauté'; diff --git a/lib/l10n/app_localizations_hu.dart b/lib/l10n/app_localizations_hu.dart index f1160bd..5d94708 100644 --- a/lib/l10n/app_localizations_hu.dart +++ b/lib/l10n/app_localizations_hu.dart @@ -3688,6 +3688,17 @@ class AppLocalizationsHu extends AppLocalizations { 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 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.'; diff --git a/lib/l10n/app_localizations_it.dart b/lib/l10n/app_localizations_it.dart index 3597901..e6412e9 100644 --- a/lib/l10n/app_localizations_it.dart +++ b/lib/l10n/app_localizations_it.dart @@ -3677,6 +3677,17 @@ class AppLocalizationsIt extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'I canali hashtag della community sono accessibili solo ai membri della community'; diff --git a/lib/l10n/app_localizations_ja.dart b/lib/l10n/app_localizations_ja.dart index aaf858d..b3e7171 100644 --- a/lib/l10n/app_localizations_ja.dart +++ b/lib/l10n/app_localizations_ja.dart @@ -3490,6 +3490,17 @@ class AppLocalizationsJa extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'コミュニティハッシュタグのチャンネルは、コミュニティのメンバーのみが参加できます。'; diff --git a/lib/l10n/app_localizations_ko.dart b/lib/l10n/app_localizations_ko.dart index d38ca2e..2d380f2 100644 --- a/lib/l10n/app_localizations_ko.dart +++ b/lib/l10n/app_localizations_ko.dart @@ -3492,6 +3492,17 @@ class AppLocalizationsKo extends AppLocalizations { 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 String get community_hashtagPrivacyHint => '커뮤니티 해시태그 채널은 커뮤니티 구성원만 가입할 수 있습니다.'; diff --git a/lib/l10n/app_localizations_nl.dart b/lib/l10n/app_localizations_nl.dart index 21a6425..b356576 100644 --- a/lib/l10n/app_localizations_nl.dart +++ b/lib/l10n/app_localizations_nl.dart @@ -3658,6 +3658,17 @@ class AppLocalizationsNl extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Community hashtag-kanalen zijn alleen toegankelijk voor leden van de community'; diff --git a/lib/l10n/app_localizations_pl.dart b/lib/l10n/app_localizations_pl.dart index bba3222..2fdc687 100644 --- a/lib/l10n/app_localizations_pl.dart +++ b/lib/l10n/app_localizations_pl.dart @@ -3690,6 +3690,17 @@ class AppLocalizationsPl extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Kanały hashtagowe społeczności są dostępne tylko dla członków społeczności'; diff --git a/lib/l10n/app_localizations_pt.dart b/lib/l10n/app_localizations_pt.dart index 9cf43c5..a251b0e 100644 --- a/lib/l10n/app_localizations_pt.dart +++ b/lib/l10n/app_localizations_pt.dart @@ -3671,6 +3671,17 @@ class AppLocalizationsPt extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Os canais de hashtag da comunidade só podem ser acessados por membros da comunidade'; diff --git a/lib/l10n/app_localizations_ru.dart b/lib/l10n/app_localizations_ru.dart index e2ee954..8c4a3d6 100644 --- a/lib/l10n/app_localizations_ru.dart +++ b/lib/l10n/app_localizations_ru.dart @@ -3679,6 +3679,17 @@ class AppLocalizationsRu extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Хэштег-каналы сообщества доступны только его участникам'; diff --git a/lib/l10n/app_localizations_sk.dart b/lib/l10n/app_localizations_sk.dart index 2d924b4..0d1ec33 100644 --- a/lib/l10n/app_localizations_sk.dart +++ b/lib/l10n/app_localizations_sk.dart @@ -3657,6 +3657,17 @@ class AppLocalizationsSk extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Hashtagové kanály komunity sú prístupné len členom komunity'; diff --git a/lib/l10n/app_localizations_sl.dart b/lib/l10n/app_localizations_sl.dart index 4119320..9dd2e0c 100644 --- a/lib/l10n/app_localizations_sl.dart +++ b/lib/l10n/app_localizations_sl.dart @@ -3652,6 +3652,17 @@ class AppLocalizationsSl extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Hashtag kanali skupnosti so dostopni samo članom skupnosti'; diff --git a/lib/l10n/app_localizations_sv.dart b/lib/l10n/app_localizations_sv.dart index 975167c..c02241f 100644 --- a/lib/l10n/app_localizations_sv.dart +++ b/lib/l10n/app_localizations_sv.dart @@ -3632,6 +3632,17 @@ class AppLocalizationsSv extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Community-hashtagkanaler kan endast nås av medlemmar i communityn'; diff --git a/lib/l10n/app_localizations_uk.dart b/lib/l10n/app_localizations_uk.dart index 3f11c3b..22d3aec 100644 --- a/lib/l10n/app_localizations_uk.dart +++ b/lib/l10n/app_localizations_uk.dart @@ -3675,6 +3675,17 @@ class AppLocalizationsUk extends AppLocalizations { 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 String get community_hashtagPrivacyHint => 'Хештег-канали спільноти доступні лише членам спільноти'; diff --git a/lib/l10n/app_localizations_zh.dart b/lib/l10n/app_localizations_zh.dart index 3a9b338..94f4d62 100644 --- a/lib/l10n/app_localizations_zh.dart +++ b/lib/l10n/app_localizations_zh.dart @@ -3395,6 +3395,17 @@ class AppLocalizationsZh extends AppLocalizations { 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 String get community_hashtagPrivacyHint => '仅社区成员可加入社区标签频道。'; diff --git a/lib/models/channel.dart b/lib/models/channel.dart index 9baf630..10135cf 100644 --- a/lib/models/channel.dart +++ b/lib/models/channel.dart @@ -68,6 +68,32 @@ class Channel { return bytes; } + /// Reference-app channel share URI: + /// `meshcore://channel/add?name=&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. /// The hashtag is normalized to include '#' prefix. /// Returns first 16 bytes of SHA256 hash as PSK. diff --git a/lib/screens/channels_screen.dart b/lib/screens/channels_screen.dart index 4072602..38c67b8 100644 --- a/lib/screens/channels_screen.dart +++ b/lib/screens/channels_screen.dart @@ -519,6 +519,17 @@ class _ChannelsScreenState extends State } }, ), + 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( leading: Icon( isMuted @@ -1764,6 +1775,20 @@ class _ChannelsScreenState extends State ); } + 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) { final connector = context.read(); diff --git a/test/models/channel_share_uri_test.dart b/test/models/channel_share_uri_test.dart new file mode 100644 index 0000000..f35fa5c --- /dev/null +++ b/test/models/channel_share_uri_test.dart @@ -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); + }); + }); +}