From 6bfc08f3ae228f68e309d5c85a0415200993d076 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 7 Mar 2026 18:30:22 +0100 Subject: [PATCH] Keep contact GPS location saved --- ios/fastlane/report.xml | 8 +- lib/models/contact.dart | 16 +- lib/screens/settings_screen.dart | 68 +++++ lib/services/route_hash_preferences.dart | 26 ++ .../contacts/contact_route_dialog.dart | 237 +++++++++--------- test/models/contact_route_codec_test.dart | 7 + 6 files changed, 238 insertions(+), 124 deletions(-) create mode 100644 lib/services/route_hash_preferences.dart diff --git a/ios/fastlane/report.xml b/ios/fastlane/report.xml index fdb44e7..fba986a 100644 --- a/ios/fastlane/report.xml +++ b/ios/fastlane/report.xml @@ -5,22 +5,22 @@ - + - + - + - + diff --git a/lib/models/contact.dart b/lib/models/contact.dart index ce80b6d..41e4f19 100644 --- a/lib/models/contact.dart +++ b/lib/models/contact.dart @@ -46,12 +46,19 @@ class ContactRouteCodec { static const int maxPathBytes = 64; static const int _unknownDescriptor = 0xFF; - static ParsedContactRoute parse(String input) { + static ParsedContactRoute parse(String input, {int? expectedHashSize}) { final normalized = input.trim().toUpperCase(); if (normalized.isEmpty) { throw const ContactRouteFormatException('Route cannot be empty.'); } + if (expectedHashSize != null && + (expectedHashSize < 1 || expectedHashSize > maxHashSize)) { + throw const ContactRouteFormatException( + 'Hash size must be 1, 2, or 3 bytes.', + ); + } + final hopTokens = normalized .split(',') .map((token) => token.trim()) @@ -80,6 +87,13 @@ class ContactRouteCodec { ); } + if (expectedHashSize != null && currentHashSize != expectedHashSize) { + throw ContactRouteFormatException( + 'Hop "$token" must be $expectedHashSize ' + 'byte${expectedHashSize == 1 ? '' : 's'}.', + ); + } + hashSize ??= currentHashSize; if (hashSize != currentHashSize) { throw const ContactRouteFormatException( diff --git a/lib/screens/settings_screen.dart b/lib/screens/settings_screen.dart index f43c0cf..e3e82c9 100644 --- a/lib/screens/settings_screen.dart +++ b/lib/screens/settings_screen.dart @@ -19,6 +19,7 @@ import '../services/update_checker_service.dart'; import '../services/voice_codec_service.dart'; import '../services/voice_bitrate_preferences.dart'; import '../services/image_preferences.dart'; +import '../services/route_hash_preferences.dart'; import '../services/image_codec_service.dart'; import '../utils/sample_data_generator.dart'; import '../utils/image_message_parser.dart'; @@ -56,6 +57,7 @@ class _SettingsScreenState extends State { bool _showRxTxIndicators = true; bool _isCheckingForUpdates = false; int _voiceBitrate = VoiceBitratePreferences.defaultBitrate; + int _routeHashSize = RouteHashPreferences.defaultHashSize; int _imageMaxSize = ImagePreferences.defaultMaxSize; int _imageCompression = ImagePreferences.defaultQuality; bool _imageGrayscale = ImagePreferences.defaultGrayscale; @@ -77,6 +79,7 @@ class _SettingsScreenState extends State { _initializeLocationService(); _loadRxTxPreference(); _loadVoiceBitratePreference(); + _loadRouteHashSizePreference(); _loadImagePreferences(); } @@ -132,6 +135,22 @@ class _SettingsScreenState extends State { return '$bitrate bps'; } + Future _loadRouteHashSizePreference() async { + final value = await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _routeHashSize = value; + }); + } + + Future _saveRouteHashSizePreference(int value) async { + await RouteHashPreferences.setHashSize(value); + if (!mounted) return; + setState(() { + _routeHashSize = value; + }); + } + Future _loadImagePreferences() async { final size = await ImagePreferences.getMaxSize(); final compression = await ImagePreferences.getCompression(); @@ -658,6 +677,46 @@ class _SettingsScreenState extends State { ); } + Future _showRouteHashSizeDialog() async { + final selected = await showDialog( + context: context, + builder: (context) => SimpleDialog( + title: const Text('Route path byte size'), + children: [ + for (final value in [1, 2, 3]) + SimpleDialogOption( + onPressed: () => Navigator.of(context).pop(value), + child: Row( + children: [ + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + Text('$value byte${value == 1 ? '' : 's'}'), + Text( + 'Use ${value * 2} hex characters per hop', + style: Theme.of(context).textTheme.bodySmall, + ), + ], + ), + ), + if (_routeHashSize == value) + Icon( + Icons.check, + color: Theme.of(context).colorScheme.primary, + ), + ], + ), + ), + ], + ), + ); + + if (selected == null) return; + await _saveRouteHashSizePreference(selected); + } + @override Widget build(BuildContext context) { return Scaffold( @@ -757,6 +816,15 @@ class _SettingsScreenState extends State { trailing: const Icon(Icons.chevron_right), onTap: () => _showLanguageDialog(), ), + ListTile( + leading: const Icon(Icons.alt_route), + title: const Text('Route path byte size'), + subtitle: Text( + '$_routeHashSize byte${_routeHashSize == 1 ? '' : 's'} for manual contact routes', + ), + trailing: const Icon(Icons.chevron_right), + onTap: _showRouteHashSizeDialog, + ), ListTile( leading: const Icon(Icons.delete_sweep, color: Colors.red), title: const Text( diff --git a/lib/services/route_hash_preferences.dart b/lib/services/route_hash_preferences.dart new file mode 100644 index 0000000..ab5674a --- /dev/null +++ b/lib/services/route_hash_preferences.dart @@ -0,0 +1,26 @@ +import 'package:shared_preferences/shared_preferences.dart'; + +class RouteHashPreferences { + static const String _hashSizeKey = 'route_hash_size'; + static const int defaultHashSize = 1; + + static Future getHashSize() async { + final prefs = await SharedPreferences.getInstance(); + final value = prefs.getInt(_hashSizeKey) ?? defaultHashSize; + return _normalize(value); + } + + static Future setHashSize(int value) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_hashSizeKey, _normalize(value)); + } + + static int normalizeSync(int value) => _normalize(value); + + static int _normalize(int value) { + if (value < 1 || value > 3) { + return defaultHashSize; + } + return value; + } +} diff --git a/lib/widgets/contacts/contact_route_dialog.dart b/lib/widgets/contacts/contact_route_dialog.dart index a54815f..f8f87fb 100644 --- a/lib/widgets/contacts/contact_route_dialog.dart +++ b/lib/widgets/contacts/contact_route_dialog.dart @@ -1,6 +1,7 @@ import 'package:flutter/material.dart'; import '../../models/contact.dart'; +import '../../services/route_hash_preferences.dart'; class ContactRouteDialog extends StatefulWidget { final Contact contact; @@ -17,11 +18,20 @@ class ContactRouteDialog extends StatefulWidget { required Contact contact, required List availableContacts, }) { - return showDialog( + return showModalBottomSheet( context: context, - builder: (context) => ContactRouteDialog( - contact: contact, - availableContacts: availableContacts, + isScrollControlled: true, + showDragHandle: true, + builder: (context) => SafeArea( + child: Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom, + ), + child: ContactRouteDialog( + contact: contact, + availableContacts: availableContacts, + ), + ), ), ); } @@ -32,20 +42,18 @@ class ContactRouteDialog extends StatefulWidget { class _ContactRouteDialogState extends State { late final TextEditingController _controller; - late int _selectedHashSize; + int _selectedHashSize = RouteHashPreferences.defaultHashSize; ParsedContactRoute? _parsedRoute; String? _errorText; @override void initState() { super.initState(); - _selectedHashSize = widget.contact.routeHasPath - ? widget.contact.routeHashSize - : 1; _controller = TextEditingController( text: widget.contact.routeCanonicalText, ); _controller.addListener(_reparse); + _loadHashSizePreference(); _reparse(); } @@ -68,10 +76,12 @@ class _ContactRouteDialogState extends State { } try { - final parsed = ContactRouteCodec.parse(input); + final parsed = ContactRouteCodec.parse( + input, + expectedHashSize: _selectedHashSize, + ); setState(() { _parsedRoute = parsed; - _selectedHashSize = parsed.hashSize; _errorText = null; }); } on ContactRouteFormatException catch (error) { @@ -82,6 +92,15 @@ class _ContactRouteDialogState extends State { } } + Future _loadHashSizePreference() async { + final hashSize = await RouteHashPreferences.getHashSize(); + if (!mounted) return; + setState(() { + _selectedHashSize = hashSize; + }); + _reparse(); + } + String _tokenFor(Contact contact, int hashSize) { final hex = contact.publicKeyHex.toUpperCase(); final length = hashSize * 2; @@ -108,123 +127,103 @@ class _ContactRouteDialogState extends State { .toList() ..sort((a, b) => a.displayName.compareTo(b.displayName)); - return AlertDialog( - title: Text('Set Route for ${widget.contact.displayName}'), - content: SizedBox( - width: 560, - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - 'Path hash size', - style: Theme.of(context).textTheme.labelLarge, + return FractionallySizedBox( + heightFactor: 0.85, + child: Padding( + padding: const EdgeInsets.fromLTRB(16, 0, 16, 16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'Set Route for ${widget.contact.displayName}', + style: Theme.of(context).textTheme.headlineSmall, + ), + const SizedBox(height: 16), + TextField( + controller: _controller, + textCapitalization: TextCapitalization.characters, + decoration: InputDecoration( + labelText: 'Route', + hintText: _selectedHashSize == 1 + ? 'AA,BB,CC' + : _selectedHashSize == 2 + ? 'AABB,CCDD' + : 'AABBCC,DDEEFF', + helperText: + 'Use comma-separated hops. Path byte size comes from global Settings. Colon form like AA:BB is also accepted.', + errorText: _errorText, + border: const OutlineInputBorder(), ), - const SizedBox(height: 8), - Wrap( - spacing: 8, - children: [1, 2, 3] - .map( - (hashSize) => ChoiceChip( - label: Text( - '$hashSize byte${hashSize == 1 ? '' : 's'}', - ), - selected: _selectedHashSize == hashSize, - onSelected: (_) { - setState(() { - _selectedHashSize = hashSize; - }); - }, + ), + const SizedBox(height: 12), + Text( + _parsedRoute == null + ? 'Preview: enter a route to validate it.' + : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', + style: Theme.of(context).textTheme.bodySmall, + ), + if (_parsedRoute != null) ...[ + const SizedBox(height: 4), + SelectableText( + _parsedRoute!.canonicalText, + style: Theme.of( + context, + ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ), + ], + const SizedBox(height: 16), + Text( + 'Pick hops from contacts', + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + if (routeCandidates.isEmpty) + const Text( + 'No repeater or room contacts are available for route building.', + ) + else + Expanded( + child: ListView.builder( + itemCount: routeCandidates.length, + itemBuilder: (context, index) { + final candidate = routeCandidates[index]; + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(candidate.displayName), + subtitle: Text( + '1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}', + style: const TextStyle(fontFamily: 'monospace'), ), - ) - .toList(), - ), - const SizedBox(height: 16), - TextField( - controller: _controller, - textCapitalization: TextCapitalization.characters, - decoration: InputDecoration( - labelText: 'Route', - hintText: _selectedHashSize == 1 - ? 'AA,BB,CC' - : _selectedHashSize == 2 - ? 'AABB,CCDD' - : 'AABBCC,DDEEFF', - helperText: - 'Use comma-separated hops. Colon form like AA:BB is also accepted.', - errorText: _errorText, - border: const OutlineInputBorder(), + trailing: TextButton( + onPressed: () => _appendHop(candidate), + child: Text( + 'Use ${_tokenFor(candidate, _selectedHashSize)}', + ), + ), + ); + }, ), ), - const SizedBox(height: 12), - Text( - _parsedRoute == null - ? 'Preview: enter a route to validate it.' - : 'Preview: ${_parsedRoute!.summary} • ${_parsedRoute!.byteLength} bytes • descriptor 0x${_parsedRoute!.encodedPathLen.toRadixString(16).padLeft(2, '0').toUpperCase()}', - style: Theme.of(context).textTheme.bodySmall, - ), - if (_parsedRoute != null) ...[ - const SizedBox(height: 4), - SelectableText( - _parsedRoute!.canonicalText, - style: Theme.of( - context, - ).textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + const SizedBox(height: 16), + Row( + children: [ + TextButton( + onPressed: () => Navigator.of(context).pop(), + child: const Text('Cancel'), + ), + const Spacer(), + FilledButton( + onPressed: _parsedRoute == null + ? null + : () => Navigator.of(context).pop(_parsedRoute), + child: const Text('Set Route'), ), ], - const SizedBox(height: 16), - Text( - 'Pick hops from contacts', - style: Theme.of(context).textTheme.labelLarge, - ), - const SizedBox(height: 8), - if (routeCandidates.isEmpty) - const Text( - 'No repeater or room contacts are available for route building.', - ) - else - ConstrainedBox( - constraints: const BoxConstraints(maxHeight: 240), - child: ListView.builder( - shrinkWrap: true, - itemCount: routeCandidates.length, - itemBuilder: (context, index) { - final candidate = routeCandidates[index]; - return ListTile( - dense: true, - contentPadding: EdgeInsets.zero, - title: Text(candidate.displayName), - subtitle: Text( - '1B ${_tokenFor(candidate, 1)} • 2B ${_tokenFor(candidate, 2)} • 3B ${_tokenFor(candidate, 3)}', - style: const TextStyle(fontFamily: 'monospace'), - ), - trailing: TextButton( - onPressed: () => _appendHop(candidate), - child: Text( - 'Use ${_tokenFor(candidate, _selectedHashSize)}', - ), - ), - ); - }, - ), - ), - ], - ), + ), + ], ), ), - actions: [ - TextButton( - onPressed: () => Navigator.of(context).pop(), - child: const Text('Cancel'), - ), - FilledButton( - onPressed: _parsedRoute == null - ? null - : () => Navigator.of(context).pop(_parsedRoute), - child: const Text('Set Route'), - ), - ], ); } } diff --git a/test/models/contact_route_codec_test.dart b/test/models/contact_route_codec_test.dart index 7595f50..23289c5 100644 --- a/test/models/contact_route_codec_test.dart +++ b/test/models/contact_route_codec_test.dart @@ -67,6 +67,13 @@ void main() { ); }); + test('rejects routes that do not match the configured hash size', () { + expect( + () => ContactRouteCodec.parse('AABB,CCDD', expectedHashSize: 1), + throwsA(isA()), + ); + }); + test('rejects invalid tokens', () { expect( () => ContactRouteCodec.parse('AA,XYZ'),