From 512b64db9fafb48c989b906af912a7a8567d8175 Mon Sep 17 00:00:00 2001 From: Janez T Date: Sat, 4 Apr 2026 15:57:41 +0200 Subject: [PATCH] fix: Preserve custom path priority --- lib/main.dart | 2 + lib/providers/app_provider.dart | 103 ++- lib/providers/offline_tiles_provider.dart | 541 +++++++++++ lib/screens/map_tab.dart | 21 +- lib/screens/offline_map_screen.dart | 842 ++++++++++++++++++ .../offline_map_caching_provider.dart | 115 +++ lib/services/offline_tile_cache_service.dart | 481 ++++++++++ lib/services/path_history_service.dart | 199 +++-- lib/services/tile_download_service.dart | 332 +++++++ lib/services/tile_math_service.dart | 268 ++++++ lib/services/tile_sharing_service.dart | 518 +++++++++++ lib/widgets/contacts/contact_tile.dart | 2 + lib/widgets/map/polygon_draw_handler.dart | 303 +++++++ test/services/path_history_service_test.dart | 94 +- 14 files changed, 3654 insertions(+), 167 deletions(-) create mode 100644 lib/providers/offline_tiles_provider.dart create mode 100644 lib/screens/offline_map_screen.dart create mode 100644 lib/services/offline_map_caching_provider.dart create mode 100644 lib/services/offline_tile_cache_service.dart create mode 100644 lib/services/tile_download_service.dart create mode 100644 lib/services/tile_math_service.dart create mode 100644 lib/services/tile_sharing_service.dart create mode 100644 lib/widgets/map/polygon_draw_handler.dart diff --git a/lib/main.dart b/lib/main.dart index 9e9e164..16947b3 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -18,6 +18,7 @@ import 'providers/channels_provider.dart'; import 'providers/voice_provider.dart'; import 'providers/image_provider.dart' as ip; import 'providers/app_provider.dart'; +import 'providers/offline_tiles_provider.dart'; import 'providers/sensors_provider.dart'; import 'services/voice_codec_service.dart'; import 'services/voice_player_service.dart'; @@ -285,6 +286,7 @@ class _MeshCoreSarAppState extends State { ), ChangeNotifierProvider(create: (_) => ChannelsProvider()), ChangeNotifierProvider(create: (_) => SensorsProvider()), + ChangeNotifierProvider(create: (_) => OfflineTilesProvider()), ChangeNotifierProvider( create: (_) { final manager = ProfileManager(); diff --git a/lib/providers/app_provider.dart b/lib/providers/app_provider.dart index 558bddb..7ab7d18 100644 --- a/lib/providers/app_provider.dart +++ b/lib/providers/app_provider.dart @@ -41,22 +41,26 @@ import '../utils/log_rx_route_decoder.dart'; class _DirectMessageRouteSession { final PathSelection currentSelection; final ParsedContactRoute? originalRoute; + final bool usedManualOverride; final bool routerFallbackAttempted; const _DirectMessageRouteSession({ required this.currentSelection, required this.originalRoute, + required this.usedManualOverride, required this.routerFallbackAttempted, }); _DirectMessageRouteSession copyWith({ PathSelection? currentSelection, ParsedContactRoute? originalRoute, + bool? usedManualOverride, bool? routerFallbackAttempted, }) { return _DirectMessageRouteSession( currentSelection: currentSelection ?? this.currentSelection, originalRoute: originalRoute ?? this.originalRoute, + usedManualOverride: usedManualOverride ?? this.usedManualOverride, routerFallbackAttempted: routerFallbackAttempted ?? this.routerFallbackAttempted, ); @@ -1044,7 +1048,6 @@ class AppProvider with ChangeNotifier { contact, devicePublicKey: devicePublicKey, ); - unawaited(_pathHistoryService.recordLearnedPath(contact)); }; // When all contacts are received @@ -1054,9 +1057,6 @@ class AppProvider with ChangeNotifier { contacts, devicePublicKey: connectionProvider.deviceInfo.publicKey, ); - for (final contact in contacts) { - unawaited(_pathHistoryService.recordLearnedPath(contact)); - } debugPrint('Received ${contacts.length} contacts'); }; @@ -1846,28 +1846,25 @@ class AppProvider with ChangeNotifier { contactsProvider.findContactByKey(contact.publicKey) ?? contact; var session = _directMessageRouteSessions[messageId]; if (session == null) { - final selection = latestContact.routeHasPath && latestContact.routeHopCount > 0 - ? PathSelection( - mode: PathSelectionMode.directCurrent, - pathBytes: Uint8List.fromList(latestContact.routePathBytes), - hopCount: latestContact.routeHopCount, - hashSize: latestContact.routeHashSize, - ) - : await _pathHistoryService.getSelectionForContact( - latestContact, - autoRouteRotationEnabled: _autoRouteRotationEnabled, - ); + final manualSelection = await _pathHistoryService + .getManualSelectionForContact(latestContact); + final selection = + manualSelection ?? + await _pathHistoryService.getSelectionForContact( + latestContact, + autoRouteRotationEnabled: _autoRouteRotationEnabled, + ); session = _DirectMessageRouteSession( currentSelection: selection, originalRoute: ContactRouteCodec.fromContact(latestContact), + usedManualOverride: manualSelection != null, routerFallbackAttempted: false, ); } if (!session.routerFallbackAttempted) { - final currentSignature = - latestContact.routeHasPath && latestContact.routeHopCount > 0 - ? latestContact.routePathBytes + final currentSignature = session.currentSelection.hasDirectPath + ? session.currentSelection.pathBytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join() : null; @@ -1897,15 +1894,6 @@ class AppProvider with ChangeNotifier { required String? currentSignature, required PathSelection fallbackSelection, }) async { - if (contact.routeHasPath && contact.routeHopCount > 0 && retryAttempt <= 1) { - return PathSelection( - mode: PathSelectionMode.directCurrent, - pathBytes: Uint8List.fromList(contact.routePathBytes), - hopCount: contact.routeHopCount, - hashSize: contact.routeHashSize, - ); - } - if (retryAttempt == 2) { return PathSelection.flood(); } @@ -2035,15 +2023,20 @@ class AppProvider with ChangeNotifier { final session = _directMessageRouteSessions[messageId] ?? _DirectMessageRouteSession( - currentSelection: latestContact.routeHasPath - ? PathSelection( - mode: PathSelectionMode.directCurrent, - pathBytes: Uint8List.fromList(latestContact.routePathBytes), - hopCount: latestContact.routeHopCount, - hashSize: latestContact.routeHashSize, - ) - : PathSelection.flood(), + currentSelection: + await _pathHistoryService.getManualSelectionForContact( + latestContact, + ) ?? + await _pathHistoryService.getSelectionForContact( + latestContact, + autoRouteRotationEnabled: _autoRouteRotationEnabled, + ), originalRoute: ContactRouteCodec.fromContact(latestContact), + usedManualOverride: + await _pathHistoryService.getManualSelectionForContact( + latestContact, + ) != + null, routerFallbackAttempted: false, ); @@ -2097,16 +2090,31 @@ class AppProvider with ChangeNotifier { } unawaited( - _pathHistoryService.recordPathResult( - contact.publicKeyHex, - session.currentSelection, - success: true, - roundTripTimeMs: roundTripTimeMs, - senderLatitude: locationTrackingService.currentPosition?.latitude, - senderLongitude: locationTrackingService.currentPosition?.longitude, - recipientLatitude: contact.displayLocation?.latitude, - recipientLongitude: contact.displayLocation?.longitude, - ), + () async { + await _pathHistoryService.recordPathResult( + contact.publicKeyHex, + session.currentSelection, + success: true, + roundTripTimeMs: roundTripTimeMs, + senderLatitude: locationTrackingService.currentPosition?.latitude, + senderLongitude: locationTrackingService.currentPosition?.longitude, + recipientLatitude: contact.displayLocation?.latitude, + recipientLongitude: contact.displayLocation?.longitude, + ); + if (!session.usedManualOverride) { + return; + } + if (session.currentSelection.mode == PathSelectionMode.directCurrent || + session.currentSelection.mode == + PathSelectionMode.directHistorical) { + await _pathHistoryService.setManualSelectionFor( + contact.publicKeyHex, + session.currentSelection, + ); + return; + } + await _pathHistoryService.clearManualRouteFor(contact.publicKeyHex); + }(), ); } @@ -2123,6 +2131,11 @@ class AppProvider with ChangeNotifier { session.currentSelection, success: false, ); + if (session.usedManualOverride) { + await _pathHistoryService.clearManualRouteFor( + latestContact.publicKeyHex, + ); + } if (session.routerFallbackAttempted) { await _restoreRouteOnDevice(latestContact, session.originalRoute); } diff --git a/lib/providers/offline_tiles_provider.dart b/lib/providers/offline_tiles_provider.dart new file mode 100644 index 0000000..84443d5 --- /dev/null +++ b/lib/providers/offline_tiles_provider.dart @@ -0,0 +1,541 @@ +import 'dart:async'; + +import 'package:flutter/foundation.dart'; +import 'package:latlong2/latlong.dart'; + +import '../models/map_layer.dart'; +import '../services/offline_tile_cache_service.dart'; +import '../services/tile_download_service.dart'; +import '../services/tile_math_service.dart'; +import '../services/tile_sharing_service.dart'; + +export '../services/tile_sharing_service.dart' show TilePeer, PeerCatalog; +export '../services/offline_tile_cache_service.dart' + show StyleInfo, DownloadRegion; + +/// Download progress state. +class DownloadProgress { + final int downloaded; + final int skipped; + final int failed; + final int total; + + const DownloadProgress({ + this.downloaded = 0, + this.skipped = 0, + this.failed = 0, + this.total = 0, + }); + + int get processed => downloaded + skipped + failed; + double get percent => total == 0 ? 0 : processed / total; + bool get isComplete => total > 0 && processed >= total; +} + +/// A downloaded/skipped tile rectangle for map overlay. +class TileOverlay { + final double north, south, east, west; + final bool isSkipped; + + const TileOverlay({ + required this.north, + required this.south, + required this.east, + required this.west, + this.isSkipped = false, + }); +} + +/// Drawing mode for polygon selection. +enum DrawingMode { none, polygon, rectangle } + +/// State management for offline tile downloading. +class OfflineTilesProvider extends ChangeNotifier { + final OfflineTileCacheService _cache = OfflineTileCacheService.instance; + final TileSharingService _sharing = TileSharingService.instance; + TileDownloadService? _downloadService; + StreamSubscription? _downloadSubscription; + StreamSubscription>? _peersSubscription; + + // Drawing state + DrawingMode _drawingMode = DrawingMode.none; + final List> _polygons = []; + List _currentVertices = []; + LatLng? _rectangleFirstCorner; + + // Download settings + int _minZoom = 8; + int _maxZoom = 14; + MapLayer _selectedLayer = MapLayer.openStreetMap; + + // Download progress + bool _isDownloading = false; + DownloadProgress _progress = const DownloadProgress(); + final List _tileOverlays = []; + + // Cache info + int _cacheSizeBytes = 0; + + // Sharing state + bool _isServerRunning = false; + Set _discoveredPeers = {}; + List _peerCatalogs = []; + bool _isFetchingCatalogs = false; + bool _isSyncing = false; + String _syncStatus = ''; + double _syncProgress = 0; + + // Local style info + List _localStyles = []; + + // Coverage overlay — which cached style's tiles to show on the map + StyleInfo? _coverageStyle; + List _coverageOverlays = []; + + // Getters + DrawingMode get drawingMode => _drawingMode; + List> get polygons => List.unmodifiable(_polygons); + List get currentVertices => List.unmodifiable(_currentVertices); + LatLng? get rectangleFirstCorner => _rectangleFirstCorner; + int get minZoom => _minZoom; + int get maxZoom => _maxZoom; + MapLayer get selectedLayer => _selectedLayer; + bool get isDownloading => _isDownloading; + DownloadProgress get progress => _progress; + List get tileOverlays => _tileOverlays; + int get cacheSizeBytes => _cacheSizeBytes; + bool get hasPolygons => _polygons.isNotEmpty; + bool get isServerRunning => _isServerRunning; + Set get discoveredPeers => _discoveredPeers; + List get peerCatalogs => _peerCatalogs; + bool get isFetchingCatalogs => _isFetchingCatalogs; + bool get isSyncing => _isSyncing; + String get syncStatus => _syncStatus; + double get syncProgress => _syncProgress; + List get localStyles => _localStyles; + StyleInfo? get coverageStyle => _coverageStyle; + List get coverageOverlays => _coverageOverlays; + + /// Estimated tile count for the current selection. + int get estimatedTileCount { + if (_polygons.isEmpty) return 0; + return TileMathService.estimateTileCount(_polygons, _minZoom, _maxZoom); + } + + // Drawing methods + + void setDrawingMode(DrawingMode mode) { + _drawingMode = mode; + _currentVertices = []; + _rectangleFirstCorner = null; + notifyListeners(); + } + + void addVertex(LatLng point) { + if (_drawingMode == DrawingMode.polygon) { + _currentVertices = [..._currentVertices, point]; + notifyListeners(); + } else if (_drawingMode == DrawingMode.rectangle) { + if (_rectangleFirstCorner == null) { + _rectangleFirstCorner = point; + notifyListeners(); + } else { + // Complete rectangle + final corner1 = _rectangleFirstCorner!; + final corner2 = point; + final rect = [ + LatLng(corner1.latitude, corner1.longitude), + LatLng(corner1.latitude, corner2.longitude), + LatLng(corner2.latitude, corner2.longitude), + LatLng(corner2.latitude, corner1.longitude), + ]; + _polygons.add(rect); + _rectangleFirstCorner = null; + _drawingMode = DrawingMode.none; + notifyListeners(); + } + } + } + + void finishPolygon() { + if (_drawingMode == DrawingMode.polygon && _currentVertices.length >= 3) { + _polygons.add(List.from(_currentVertices)); + _currentVertices = []; + _drawingMode = DrawingMode.none; + notifyListeners(); + } + } + + void removePolygon(int index) { + if (index >= 0 && index < _polygons.length) { + _polygons.removeAt(index); + notifyListeners(); + } + } + + void clearPolygons() { + _polygons.clear(); + _currentVertices = []; + _rectangleFirstCorner = null; + _drawingMode = DrawingMode.none; + notifyListeners(); + } + + void undoLastVertex() { + if (_currentVertices.isNotEmpty) { + _currentVertices = _currentVertices.sublist(0, _currentVertices.length - 1); + notifyListeners(); + } + } + + // Download settings + + void setMinZoom(int zoom) { + _minZoom = zoom.clamp(0, 19); + if (_maxZoom < _minZoom) _maxZoom = _minZoom; + notifyListeners(); + } + + void setMaxZoom(int zoom) { + _maxZoom = zoom.clamp(0, 19); + if (_minZoom > _maxZoom) _minZoom = _maxZoom; + notifyListeners(); + } + + void setSelectedLayer(MapLayer layer) { + _selectedLayer = layer; + notifyListeners(); + } + + // Download control + + Future startDownload() async { + if (_isDownloading || _polygons.isEmpty) return; + + _isDownloading = true; + _progress = const DownloadProgress(); + _tileOverlays.clear(); + notifyListeners(); + + _downloadService = TileDownloadService(); + final stream = _downloadService!.downloadTiles( + polygons: _polygons, + minZoom: _minZoom, + maxZoom: _maxZoom, + urlTemplate: _selectedLayer.urlTemplate, + displayName: _selectedLayer.name, + ); + + await for (final event in stream) { + switch (event) { + case TileDownloadStarted(:final totalTiles): + _progress = DownloadProgress(total: totalTiles); + notifyListeners(); + + case TileDownloaded(:final north, :final south, :final east, :final west): + _progress = DownloadProgress( + downloaded: _progress.downloaded + 1, + skipped: _progress.skipped, + failed: _progress.failed, + total: _progress.total, + ); + _addOverlay(TileOverlay( + north: north, south: south, east: east, west: west, + )); + notifyListeners(); + + case TileSkipped(): + _progress = DownloadProgress( + downloaded: _progress.downloaded, + skipped: _progress.skipped + 1, + failed: _progress.failed, + total: _progress.total, + ); + notifyListeners(); + + case TileBatchSkipped(:final count): + _progress = DownloadProgress( + downloaded: _progress.downloaded, + skipped: _progress.skipped + count, + failed: _progress.failed, + total: _progress.total, + ); + notifyListeners(); + + case TileFailed(): + _progress = DownloadProgress( + downloaded: _progress.downloaded, + skipped: _progress.skipped, + failed: _progress.failed + 1, + total: _progress.total, + ); + notifyListeners(); + + case TileDownloadComplete(): + _isDownloading = false; + notifyListeners(); + + case TileDownloadCancelled(): + _isDownloading = false; + notifyListeners(); + } + } + + _isDownloading = false; + _downloadService?.dispose(); + _downloadService = null; + notifyListeners(); + } + + void cancelDownload() { + _downloadService?.cancel(); + } + + void clearOverlays() { + _tileOverlays.clear(); + notifyListeners(); + } + + void _addOverlay(TileOverlay overlay) { + _tileOverlays.add(overlay); + // Limit overlays to prevent OOM + if (_tileOverlays.length > 500) { + _tileOverlays.removeRange(0, _tileOverlays.length - 500); + } + } + + // Cache management + + Future refreshCacheSize() async { + _cacheSizeBytes = await _cache.getCacheSize(); + notifyListeners(); + } + + Future deleteStyle(StyleInfo style) async { + if (_coverageStyle?.hash == style.hash) hideCoverage(); + await _cache.deleteStyle(style.hash); + await refreshCacheSize(); + await refreshLocalStyles(); + } + + Future clearCache() async { + hideCoverage(); + await _cache.clearCache(); + _cacheSizeBytes = 0; + _localStyles = []; + notifyListeners(); + } + + // Coverage overlay — show cached tile bounds on the map + + /// Show the coverage of a cached style on the map. + /// Loads tile coordinates from the manifest and converts to bounds. + Future showCoverage(StyleInfo style) async { + if (_coverageStyle?.hash == style.hash) { + // Toggle off if same style tapped again + hideCoverage(); + return; + } + + _coverageStyle = style; + _coverageOverlays = []; + notifyListeners(); + + final manifest = await _cache.loadManifest(style.hash); + + // Convert manifest keys to tile bound overlays + final overlays = []; + for (final key in manifest) { + final parts = key.split('/'); + if (parts.length != 3) continue; + final z = int.tryParse(parts[0]); + final x = int.tryParse(parts[1]); + final y = int.tryParse(parts[2]); + if (z == null || x == null || y == null) continue; + + final bounds = TileMathService.tileBounds(x, y, z); + overlays.add(TileOverlay( + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: bounds.west, + isSkipped: true, // green color + )); + } + + _coverageOverlays = overlays; + notifyListeners(); + } + + void hideCoverage() { + _coverageStyle = null; + _coverageOverlays = []; + notifyListeners(); + } + + // Sharing controls + + Future toggleServer() async { + if (_isServerRunning) { + await _sharing.stopServer(); + _isServerRunning = false; + } else { + await _sharing.startServer(); + _isServerRunning = _sharing.isRunning; + } + notifyListeners(); + } + + Future startPeerDiscovery() async { + _peersSubscription?.cancel(); + _peersSubscription = _sharing.peersStream.listen((peers) { + _discoveredPeers = peers; + notifyListeners(); + }); + await _sharing.startDiscovery(); + } + + Future stopPeerDiscovery() async { + _peersSubscription?.cancel(); + _peersSubscription = null; + await _sharing.stopPeerDiscovery(); + _discoveredPeers = {}; + notifyListeners(); + } + + void addManualPeer(String ipAddress) { + _sharing.addManualPeer(ipAddress); + _discoveredPeers = _sharing.discoveredPeers; + notifyListeners(); + } + + void removePeer(TilePeer peer) { + _sharing.removePeer(peer); + _discoveredPeers = _sharing.discoveredPeers; + notifyListeners(); + } + + // Peer catalog & P2P sync + + /// Refresh local style info. + Future refreshLocalStyles() async { + _localStyles = await _cache.listStylesDetailed(); + notifyListeners(); + } + + /// Fetch catalogs from all discovered peers to see what they have. + Future refreshPeerCatalogs() async { + _isFetchingCatalogs = true; + notifyListeners(); + + _peerCatalogs = await _sharing.fetchAllPeerCatalogs(); + _isFetchingCatalogs = false; + notifyListeners(); + } + + /// Sync a style from one or more peers that have it. + /// Finds all peers offering [styleHash] and pulls missing tiles. + Future syncStyleFromPeers(StyleInfo style) async { + if (_isSyncing) return; + + // Find all peers that have this style + final peersWithStyle = []; + for (final catalog in _peerCatalogs) { + if (catalog.styles.any((s) => s.hash == style.hash)) { + peersWithStyle.add(catalog.peer); + } + } + if (peersWithStyle.isEmpty) return; + + _isSyncing = true; + _syncStatus = 'Starting sync of ${style.displayName}...'; + _syncProgress = 0; + notifyListeners(); + + final stream = _sharing.syncStyleFromPeers( + peers: peersWithStyle, + styleHash: style.hash, + styleMeta: style, + ); + + await for (final event in stream) { + switch (event) { + case PeerSyncStarted(:final totalTiles): + _syncStatus = 'Syncing ${style.displayName}: 0/$totalTiles tiles'; + _syncProgress = 0; + notifyListeners(); + + case PeerSyncTileDownloaded(:final downloaded, :final total): + _syncStatus = + 'Syncing ${style.displayName}: $downloaded/$total tiles'; + _syncProgress = total > 0 ? downloaded / total : 0; + notifyListeners(); + + case PeerSyncTileSkipped(:final skipped, :final total): + _syncProgress = total > 0 ? skipped / total : 0; + notifyListeners(); + + case PeerSyncComplete(:final downloaded, :final skipped, :final failed): + _syncStatus = + 'Done! $downloaded new, $skipped cached, $failed failed'; + _isSyncing = false; + notifyListeners(); + await refreshCacheSize(); + await refreshLocalStyles(); + + case PeerSyncCancelled(): + _syncStatus = 'Sync cancelled'; + _isSyncing = false; + notifyListeners(); + } + } + } + + void cancelSync() { + _sharing.cancelSync(); + } + + // Presets — load a previously downloaded region for quick re-download + + /// Load a saved download region as the current selection. + /// Restores polygons, zoom range, and map layer. + void loadPreset(StyleInfo style) { + if (style.region == null) return; + + final region = style.region!; + + // Restore polygons + _polygons.clear(); + for (final polyData in region.polygons) { + final poly = polyData.map((v) => LatLng(v[0], v[1])).toList(); + if (poly.length >= 3) _polygons.add(poly); + } + + // Restore zoom range + _minZoom = region.minZoom; + _maxZoom = region.maxZoom; + + // Try to find the matching map layer + if (style.urlTemplate.isNotEmpty) { + final matchingLayer = MapLayer.allLayers.where( + (l) => l.urlTemplate == style.urlTemplate, + ); + if (matchingLayer.isNotEmpty) { + _selectedLayer = matchingLayer.first; + } + } + + _currentVertices = []; + _rectangleFirstCorner = null; + _drawingMode = DrawingMode.none; + notifyListeners(); + } + + @override + void dispose() { + _downloadSubscription?.cancel(); + _downloadService?.dispose(); + _peersSubscription?.cancel(); + super.dispose(); + } +} diff --git a/lib/screens/map_tab.dart b/lib/screens/map_tab.dart index 198e3ad..e984e53 100644 --- a/lib/screens/map_tab.dart +++ b/lib/screens/map_tab.dart @@ -43,6 +43,8 @@ import '../widgets/messages/sar_update_sheet.dart'; import '../utils/key_comparison.dart'; import '../utils/sar_message_parser.dart'; import '../l10n/app_localizations.dart'; +import '../services/offline_map_caching_provider.dart'; +import 'offline_map_screen.dart'; class MapTab extends StatefulWidget { final Function(bool)? onFullscreenChanged; @@ -61,9 +63,11 @@ class MapTab extends StatefulWidget { class _MapTabState extends State with AutomaticKeepAliveClientMixin { final MapController _mapController = MapController(); static final TileProvider _tileProvider = NetworkTileProvider( - cachingProvider: BuiltInMapCachingProvider.getOrCreateInstance( - maxCacheSize: 10_000_000_000, - overrideFreshAge: const Duration(days: 365), + cachingProvider: OfflineMapCachingProvider( + BuiltInMapCachingProvider.getOrCreateInstance( + maxCacheSize: 10_000_000_000, + overrideFreshAge: const Duration(days: 365), + ), ), ); // DO NOT create a new LocationTrackingService instance here @@ -3229,6 +3233,17 @@ class _MapTabState extends State with AutomaticKeepAliveClientMixin { child: const Icon(Icons.layers), ), const SizedBox(height: 8), + FloatingActionButton.small( + heroTag: 'offline_maps', + onPressed: () => Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const OfflineMapScreen(), + ), + ), + child: const Icon(Icons.download_for_offline), + ), + const SizedBox(height: 8), FloatingActionButton.small( heroTag: 'fullscreen_toggle', onPressed: () { diff --git a/lib/screens/offline_map_screen.dart b/lib/screens/offline_map_screen.dart new file mode 100644 index 0000000..2ded07c --- /dev/null +++ b/lib/screens/offline_map_screen.dart @@ -0,0 +1,842 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../models/map_layer.dart'; +import '../providers/offline_tiles_provider.dart'; +import '../widgets/map/polygon_draw_handler.dart'; + +/// Screen for downloading offline map tiles. +/// +/// Allows drawing polygons/rectangles to select areas, choosing zoom levels, +/// and downloading tiles with real-time progress visualization. +class OfflineMapScreen extends StatefulWidget { + const OfflineMapScreen({super.key}); + + @override + State createState() => _OfflineMapScreenState(); +} + +class _OfflineMapScreenState extends State { + final MapController _mapController = MapController(); + late OfflineTilesProvider _provider; + double _currentZoom = 8; + + @override + void initState() { + super.initState(); + _provider = context.read(); + _provider.refreshCacheSize(); + _provider.refreshLocalStyles(); + _provider.startPeerDiscovery(); + } + + @override + void dispose() { + _provider.stopPeerDiscovery(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Scaffold( + appBar: AppBar( + title: const Text('Offline Maps'), + actions: [ + Consumer( + builder: (context, provider, _) { + return IconButton( + icon: Badge( + isLabelVisible: provider.discoveredPeers.isNotEmpty, + label: Text('${provider.discoveredPeers.length}'), + child: Icon( + provider.isServerRunning + ? Icons.wifi_tethering + : Icons.wifi_tethering_off, + ), + ), + tooltip: provider.isServerRunning + ? 'Sharing tiles' + : 'Tile sharing off', + onPressed: () => _showSharingSheet(context, provider), + ); + }, + ), + Consumer( + builder: (context, provider, _) { + return PopupMenuButton( + icon: const Icon(Icons.layers), + tooltip: 'Map Style', + onSelected: (layer) => provider.setSelectedLayer(layer), + itemBuilder: (_) => [ + for (final layer in MapLayer.allLayers) + PopupMenuItem( + value: layer, + child: Row( + children: [ + if (layer.type == provider.selectedLayer.type) + const Icon(Icons.check, size: 18) + else + const SizedBox(width: 18), + const SizedBox(width: 8), + Text(layer.name), + ], + ), + ), + ], + ); + }, + ), + ], + ), + body: Stack( + children: [ + Consumer( + builder: (context, provider, _) { + return FlutterMap( + mapController: _mapController, + options: MapOptions( + initialCenter: const LatLng(46.05, 14.5), // Slovenia + initialZoom: 8, + onPositionChanged: (camera, hasGesture) { + if (camera.zoom != _currentZoom) { + setState(() => _currentZoom = camera.zoom); + } + }, + onTap: (tapPosition, point) { + if (provider.drawingMode != DrawingMode.none) { + provider.addVertex(point); + } + }, + ), + children: [ + TileLayer( + urlTemplate: provider.selectedLayer.urlTemplate, + userAgentPackageName: 'com.meshcore.sar', + maxZoom: provider.selectedLayer.maxZoom, + ), + CoverageLayer(currentZoom: _currentZoom), + const PolygonDrawLayer(), + const DownloadProgressLayer(), + ], + ); + }, + ), + const DrawingToolbar(), + Positioned( + left: 16, + top: 16, + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.85), + borderRadius: BorderRadius.circular(8), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 4, + ), + ], + ), + child: Text( + 'Z ${_currentZoom.toStringAsFixed(1)}', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ), + ), + _buildBottomPanel(), + ], + ), + ); + } + + Widget _buildBottomPanel() { + return Positioned( + left: 0, + right: 0, + bottom: 0, + child: Consumer( + builder: (context, provider, _) { + return Container( + decoration: BoxDecoration( + color: Theme.of(context).colorScheme.surface, + borderRadius: + const BorderRadius.vertical(top: Radius.circular(16)), + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.15), + blurRadius: 10, + offset: const Offset(0, -2), + ), + ], + ), + child: SafeArea( + top: false, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + // Saved region presets dropdown + if (!provider.isDownloading && + provider.localStyles + .any((s) => s.region != null)) ...[ + DropdownButtonFormField( + decoration: const InputDecoration( + prefixIcon: Icon(Icons.bookmark, size: 20), + labelText: 'Saved regions', + isDense: true, + contentPadding: EdgeInsets.symmetric( + horizontal: 12, vertical: 8), + border: OutlineInputBorder(), + ), + isExpanded: true, + hint: const Text('Load a saved region'), + items: provider.localStyles + .where((s) => s.region != null) + .map((style) => DropdownMenuItem( + value: style, + child: Text( + '${style.displayName} ' + '(z${style.region!.minZoom}-${style.region!.maxZoom}, ' + '${_formatNumber(style.tileCount)} tiles)', + overflow: TextOverflow.ellipsis, + ), + )) + .toList(), + onChanged: (style) { + if (style != null) { + provider.loadPreset(style); + _fitMapToPolygons(provider); + } + }, + ), + const SizedBox(height: 8), + ], + + // Zoom range + if (!provider.isDownloading) ...[ + Row( + children: [ + Expanded( + child: _ZoomSelector( + label: 'Min Zoom', + value: provider.minZoom, + onChanged: provider.setMinZoom, + ), + ), + const SizedBox(width: 16), + Expanded( + child: _ZoomSelector( + label: 'Max Zoom', + value: provider.maxZoom, + onChanged: provider.setMaxZoom, + ), + ), + ], + ), + const SizedBox(height: 8), + // Tile estimate + Text( + provider.hasPolygons + ? '~${_formatNumber(provider.estimatedTileCount)} tiles' + : 'Draw an area to download', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + const SizedBox(height: 4), + Text( + 'Cache: ${_formatBytes(provider.cacheSizeBytes)}', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + textAlign: TextAlign.center, + ), + ], + + // Progress + if (provider.isDownloading) ...[ + LinearProgressIndicator( + value: provider.progress.percent, + ), + const SizedBox(height: 8), + Text( + '${(provider.progress.percent * 100).toStringAsFixed(1)}% — ' + 'Downloaded: ${provider.progress.downloaded}, ' + 'Cached: ${provider.progress.skipped}, ' + 'Failed: ${provider.progress.failed} / ' + '${provider.progress.total}', + style: Theme.of(context).textTheme.bodySmall, + textAlign: TextAlign.center, + ), + ], + + // Download complete summary + if (!provider.isDownloading && + provider.progress.isComplete) ...[ + const SizedBox(height: 4), + Text( + 'Done! ${provider.progress.downloaded} downloaded, ' + '${provider.progress.skipped} cached, ' + '${provider.progress.failed} failed', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Colors.green, + ), + textAlign: TextAlign.center, + ), + ], + + const SizedBox(height: 12), + + // Action buttons + Row( + children: [ + if (!provider.isDownloading) ...[ + Expanded( + child: FilledButton.icon( + onPressed: provider.hasPolygons + ? () => provider.startDownload() + : null, + icon: const Icon(Icons.download), + label: const Text('Download'), + ), + ), + if (provider.tileOverlays.isNotEmpty) ...[ + const SizedBox(width: 8), + IconButton( + onPressed: provider.clearOverlays, + icon: const Icon(Icons.layers_clear), + tooltip: 'Clear overlay', + ), + ], + if (provider.cacheSizeBytes > 0) ...[ + const SizedBox(width: 8), + IconButton( + onPressed: () => _confirmClearCache(provider), + icon: const Icon(Icons.delete_forever), + tooltip: 'Clear cache', + ), + ], + ], + if (provider.isDownloading) ...[ + Expanded( + child: OutlinedButton.icon( + onPressed: () => provider.cancelDownload(), + icon: const Icon(Icons.cancel), + label: const Text('Cancel'), + style: OutlinedButton.styleFrom( + foregroundColor: Colors.red, + ), + ), + ), + ], + ], + ), + ], + ), + ), + ), + ); + }, + ), + ); + } + + void _showSharingSheet(BuildContext context, OfflineTilesProvider provider) { + // Refresh catalogs and local styles when opening + provider.refreshPeerCatalogs(); + provider.refreshLocalStyles(); + + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (sheetContext) { + return DraggableScrollableSheet( + initialChildSize: 0.65, + minChildSize: 0.3, + maxChildSize: 0.9, + expand: false, + builder: (context, scrollController) { + return Consumer( + builder: (context, provider, _) { + return ListView( + controller: scrollController, + padding: const EdgeInsets.all(16), + children: [ + // Header + Center( + child: Container( + width: 32, + height: 4, + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: Colors.grey[400], + borderRadius: BorderRadius.circular(2), + ), + ), + ), + Text( + 'Tile Sharing', + style: Theme.of(context).textTheme.titleMedium, + ), + const SizedBox(height: 12), + + // Server toggle + SwitchListTile( + title: const Text('Share my tiles'), + subtitle: Text( + provider.isServerRunning + ? 'Other devices can fetch tiles from this device' + : 'Start serving cached tiles to nearby devices', + ), + secondary: Icon( + provider.isServerRunning + ? Icons.wifi_tethering + : Icons.wifi_tethering_off, + ), + value: provider.isServerRunning, + onChanged: (_) => provider.toggleServer(), + ), + + // My cached maps + if (provider.localStyles.isNotEmpty) ...[ + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'My Cached Maps', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + ...provider.localStyles.map((style) { + final isShowing = + provider.coverageStyle?.hash == style.hash; + return ListTile( + dense: true, + leading: const Icon(Icons.map, size: 20), + title: Text(style.displayName), + subtitle: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)}', + ), + trailing: Row( + mainAxisSize: MainAxisSize.min, + children: [ + IconButton( + icon: Icon( + isShowing + ? Icons.visibility + : Icons.visibility_off, + size: 20, + color: isShowing + ? Colors.blue + : null, + ), + tooltip: isShowing + ? 'Hide on map' + : 'Show on map', + onPressed: () { + provider.showCoverage(style); + }, + ), + IconButton( + icon: const Icon(Icons.delete_outline, + size: 20), + tooltip: 'Delete', + onPressed: () => _confirmDeleteStyle( + context, provider, style), + ), + ], + ), + ); + }), + ], + + // Peers section + const Divider(), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + 'Nearby Devices', + style: Theme.of(context).textTheme.titleSmall, + ), + ), + if (provider.isFetchingCatalogs) + const SizedBox( + width: 16, + height: 16, + child: + CircularProgressIndicator(strokeWidth: 2), + ) + else + IconButton( + icon: const Icon(Icons.refresh, size: 20), + tooltip: 'Refresh', + onPressed: () => + provider.refreshPeerCatalogs(), + ), + IconButton( + icon: const Icon(Icons.add, size: 20), + tooltip: 'Add peer manually', + onPressed: () => + _showAddPeerDialog(context, provider), + ), + ], + ), + ), + + if (provider.discoveredPeers.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Text( + 'No peers found on the local network.\n' + 'Make sure other devices have tile sharing enabled.', + style: Theme.of(context) + .textTheme + .bodySmall + ?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ), + + // Peer catalogs — show each peer and their available styles + ...provider.peerCatalogs.map((catalog) => _buildPeerCard( + context, provider, catalog)), + + // Peers without catalogs yet (just discovered, not queried) + ...provider.discoveredPeers + .where((peer) => !provider.peerCatalogs + .any((c) => c.peer == peer)) + .map((peer) => ListTile( + leading: const Icon(Icons.devices), + title: Text(peer.ipAddress), + subtitle: const Text('Fetching catalog...'), + trailing: IconButton( + icon: const Icon( + Icons.remove_circle_outline, + size: 20), + onPressed: () => + provider.removePeer(peer), + ), + )), + + // Sync progress + if (provider.isSyncing || provider.syncStatus.isNotEmpty) ...[ + const Divider(), + if (provider.isSyncing) + LinearProgressIndicator( + value: provider.syncProgress > 0 + ? provider.syncProgress + : null, + ), + Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + children: [ + Expanded( + child: Text( + provider.syncStatus, + style: Theme.of(context).textTheme.bodySmall, + ), + ), + if (provider.isSyncing) + TextButton( + onPressed: () => provider.cancelSync(), + child: const Text('Cancel'), + ), + ], + ), + ), + ], + ], + ); + }, + ); + }, + ); + }, + ); + } + + Widget _buildPeerCard( + BuildContext context, + OfflineTilesProvider provider, + PeerCatalog catalog, + ) { + return Card( + margin: const EdgeInsets.symmetric(vertical: 4), + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Icon(Icons.devices, size: 18), + const SizedBox(width: 8), + Expanded( + child: Text( + catalog.peer.ipAddress, + style: Theme.of(context).textTheme.titleSmall, + ), + ), + IconButton( + icon: const Icon(Icons.remove_circle_outline, size: 18), + onPressed: () => provider.removePeer(catalog.peer), + visualDensity: VisualDensity.compact, + ), + ], + ), + if (catalog.styles.isEmpty) + Padding( + padding: const EdgeInsets.only(top: 4), + child: Text( + 'No cached tiles on this device', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context) + .colorScheme + .onSurface + .withValues(alpha: 0.6), + ), + ), + ), + ...catalog.styles.map((style) { + // Check if we already have this style locally + final localMatch = provider.localStyles + .where((s) => s.hash == style.hash); + final localCount = + localMatch.isNotEmpty ? localMatch.first.tileCount : 0; + final missingTiles = style.tileCount - localCount; + + return ListTile( + dense: true, + contentPadding: EdgeInsets.zero, + leading: const Icon(Icons.map_outlined, size: 20), + title: Text(style.displayName), + subtitle: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)}' + '${localCount > 0 ? ' (you have ${_formatNumberInline(localCount)})' : ''}', + ), + trailing: missingTiles > 0 + ? TextButton.icon( + onPressed: provider.isSyncing + ? null + : () => provider.syncStyleFromPeers(style), + icon: const Icon(Icons.download, size: 16), + label: Text( + missingTiles == style.tileCount + ? 'Get all' + : '+${_formatNumber(missingTiles)}', + ), + ) + : const Icon(Icons.check_circle, + color: Colors.green, size: 20), + ); + }), + ], + ), + ), + ); + } + + String _formatNumberInline(int n) { + if (n < 1000) return '$n'; + if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K'; + return '${(n / 1000000).toStringAsFixed(1)}M'; + } + + void _fitMapToPolygons(OfflineTilesProvider provider) { + if (provider.polygons.isEmpty) return; + + var minLat = 90.0, maxLat = -90.0; + var minLng = 180.0, maxLng = -180.0; + for (final poly in provider.polygons) { + for (final p in poly) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLng) minLng = p.longitude; + if (p.longitude > maxLng) maxLng = p.longitude; + } + } + + _mapController.fitCamera( + CameraFit.bounds( + bounds: LatLngBounds( + LatLng(minLat, minLng), + LatLng(maxLat, maxLng), + ), + padding: const EdgeInsets.all(50), + ), + ); + } + + void _showAddPeerDialog( + BuildContext context, OfflineTilesProvider provider) { + final controller = TextEditingController(); + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Add Peer'), + content: TextField( + controller: controller, + decoration: const InputDecoration( + labelText: 'IP Address', + hintText: '192.168.1.100', + ), + keyboardType: TextInputType.number, + autofocus: true, + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + provider.addManualPeer(value.trim()); + Navigator.pop(context); + } + }, + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + final ip = controller.text.trim(); + if (ip.isNotEmpty) { + provider.addManualPeer(ip); + Navigator.pop(context); + } + }, + child: const Text('Add'), + ), + ], + ), + ); + } + + void _confirmDeleteStyle( + BuildContext context, + OfflineTilesProvider provider, + StyleInfo style, + ) { + showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text('Delete ${style.displayName}?'), + content: Text( + '${_formatNumber(style.tileCount)} tiles, ' + '${_formatBytes(style.sizeBytes)} will be deleted.', + ), + actions: [ + TextButton( + onPressed: () => Navigator.pop(dialogContext), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + provider.deleteStyle(style); + Navigator.pop(dialogContext); + }, + child: + const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + void _confirmClearCache(OfflineTilesProvider provider) { + showDialog( + context: context, + builder: (context) => AlertDialog( + title: const Text('Clear offline cache?'), + content: Text( + 'This will delete ${_formatBytes(provider.cacheSizeBytes)} of cached tiles.'), + actions: [ + TextButton( + onPressed: () => Navigator.pop(context), + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + provider.clearCache(); + Navigator.pop(context); + }, + child: + const Text('Delete', style: TextStyle(color: Colors.red)), + ), + ], + ), + ); + } + + String _formatBytes(int bytes) { + if (bytes < 1024) return '$bytes B'; + if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB'; + if (bytes < 1024 * 1024 * 1024) { + return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB'; + } + return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB'; + } + + String _formatNumber(int n) { + if (n < 1000) return '$n'; + if (n < 1000000) return '${(n / 1000).toStringAsFixed(1)}K'; + return '${(n / 1000000).toStringAsFixed(1)}M'; + } +} + +class _ZoomSelector extends StatelessWidget { + final String label; + final int value; + final ValueChanged onChanged; + + const _ZoomSelector({ + required this.label, + required this.value, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Row( + children: [ + Text(label, style: Theme.of(context).textTheme.bodySmall), + const SizedBox(width: 8), + Expanded( + child: Slider( + value: value.toDouble(), + min: 0, + max: 19, + divisions: 19, + label: '$value', + onChanged: (v) => onChanged(v.round()), + ), + ), + SizedBox( + width: 24, + child: Text( + '$value', + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ), + ], + ); + } +} diff --git a/lib/services/offline_map_caching_provider.dart b/lib/services/offline_map_caching_provider.dart new file mode 100644 index 0000000..80c7126 --- /dev/null +++ b/lib/services/offline_map_caching_provider.dart @@ -0,0 +1,115 @@ +import 'dart:typed_data'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter_map/flutter_map.dart'; + +import 'offline_tile_cache_service.dart'; +import 'tile_sharing_service.dart'; + +/// A [MapCachingProvider] that checks the offline AVIF tile cache (and +/// optionally peers) before falling through to the built-in cache. +/// +/// This allows preloaded tiles to be served during normal map browsing. +class OfflineMapCachingProvider implements MapCachingProvider { + final MapCachingProvider _delegate; + final OfflineTileCacheService _cache = OfflineTileCacheService.instance; + final TileSharingService _sharing = TileSharingService.instance; + + OfflineMapCachingProvider(this._delegate); + + @override + bool get isSupported => true; + + @override + Future getTile(String url) async { + // Extract tile coordinates from URL to check our AVIF cache + final coords = _parseTileUrl(url); + if (coords != null) { + final styleHash = _cache.styleHashFromUrl(_extractUrlTemplate(url)); + + // Check local AVIF cache first + final pngBytes = await _cache.getTileAsPng( + styleHash, coords.z, coords.x, coords.y); + if (pngBytes != null) { + return ( + bytes: pngBytes, + metadata: CachedMapTileMetadata( + staleAt: DateTime.now().add(const Duration(days: 365)), + lastModified: null, + etag: null, + ), + ); + } + + // Try peers + if (_sharing.discoveredPeers.isNotEmpty) { + final avifBytes = await _sharing.fetchFromAnyPeer( + styleHash, coords.z, coords.x, coords.y); + if (avifBytes != null) { + // Cache locally for next time + await _cache.putRawTile( + styleHash, coords.z, coords.x, coords.y, avifBytes); + final decoded = await OfflineTileCacheService.getTileAsPngStatic(avifBytes); + if (decoded != null) { + return ( + bytes: decoded, + metadata: CachedMapTileMetadata( + staleAt: DateTime.now().add(const Duration(days: 365)), + lastModified: null, + etag: null, + ), + ); + } + } + } + } + + // Fall through to delegate (built-in cache) + return _delegate.getTile(url); + } + + @override + Future putTile({ + required String url, + required CachedMapTileMetadata metadata, + Uint8List? bytes, + }) { + // Only delegate to built-in cache for normal browsing tiles + return _delegate.putTile(url: url, metadata: metadata, bytes: bytes); + } + + /// Parse z/x/y from a tile URL. + static _TileCoords? _parseTileUrl(String url) { + // Match common patterns: /{z}/{x}/{y}.png, /tile/{z}/{y}/{x}, etc. + final patterns = [ + RegExp(r'/(\d+)/(\d+)/(\d+)\.(?:png|jpg|jpeg|webp)'), + RegExp(r'/(\d+)/(\d+)/(\d+)$'), + ]; + + for (final pattern in patterns) { + final match = pattern.firstMatch(url); + if (match != null) { + return _TileCoords( + z: int.parse(match.group(1)!), + x: int.parse(match.group(2)!), + y: int.parse(match.group(3)!), + ); + } + } + return null; + } + + /// Extract a URL template from a concrete URL by replacing coordinates. + static String _extractUrlTemplate(String url) { + // Replace the last three numeric path segments with placeholders + return url.replaceAllMapped( + RegExp(r'/(\d+)/(\d+)/(\d+)(\.(?:png|jpg|jpeg|webp))?$'), + (m) => '/{z}/{x}/{y}${m.group(4) ?? ''}', + ); + } +} + +class _TileCoords { + final int z, x, y; + const _TileCoords({required this.z, required this.x, required this.y}); +} diff --git a/lib/services/offline_tile_cache_service.dart b/lib/services/offline_tile_cache_service.dart new file mode 100644 index 0000000..006772d --- /dev/null +++ b/lib/services/offline_tile_cache_service.dart @@ -0,0 +1,481 @@ +import 'dart:convert'; +import 'dart:io'; +import 'dart:ui' as ui; + +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter_avif/flutter_avif.dart'; +import 'package:path_provider/path_provider.dart'; + +/// A saved download region (polygons + zoom range) for quick re-download. +class DownloadRegion { + final List>> polygons; // [polygon][vertex][lat, lng] + final int minZoom; + final int maxZoom; + + const DownloadRegion({ + required this.polygons, + required this.minZoom, + required this.maxZoom, + }); + + Map toJson() => { + 'polygons': polygons, + 'minZoom': minZoom, + 'maxZoom': maxZoom, + }; + + factory DownloadRegion.fromJson(Map json) { + final rawPolygons = json['polygons'] as List? ?? []; + final polygons = rawPolygons.map>>((poly) { + return (poly as List).map>((vertex) { + final v = vertex as List; + return [ + (v[0] as num).toDouble(), + (v[1] as num).toDouble(), + ]; + }).toList(); + }).toList(); + return DownloadRegion( + polygons: polygons, + minZoom: json['minZoom'] as int? ?? 8, + maxZoom: json['maxZoom'] as int? ?? 14, + ); + } +} + +/// Metadata about a cached map style. +class StyleInfo { + final String hash; + final String displayName; + final String urlTemplate; + final int tileCount; + final int sizeBytes; + final DownloadRegion? region; + + const StyleInfo({ + required this.hash, + required this.displayName, + required this.urlTemplate, + this.tileCount = 0, + this.sizeBytes = 0, + this.region, + }); + + Map toJson() => { + 'hash': hash, + 'displayName': displayName, + 'urlTemplate': urlTemplate, + 'tileCount': tileCount, + 'sizeBytes': sizeBytes, + if (region != null) 'region': region!.toJson(), + }; + + factory StyleInfo.fromJson(Map json) => StyleInfo( + hash: json['hash'] as String, + displayName: json['displayName'] as String? ?? json['hash'] as String, + urlTemplate: json['urlTemplate'] as String? ?? '', + tileCount: json['tileCount'] as int? ?? 0, + sizeBytes: json['sizeBytes'] as int? ?? 0, + region: json['region'] != null + ? DownloadRegion.fromJson(json['region'] as Map) + : null, + ); +} + +/// A tile coordinate in the cache (z/x/y). +class CachedTileCoord { + final int z, x, y; + const CachedTileCoord(this.z, this.x, this.y); + + Map toJson() => {'z': z, 'x': x, 'y': y}; + + factory CachedTileCoord.fromJson(Map json) => + CachedTileCoord( + json['z'] as int, + json['x'] as int, + json['y'] as int, + ); +} + +/// Manages the offline AVIF tile cache on disk. +/// +/// Tiles are stored as `{baseDir}/offline_tiles/{styleHash}/{z}/{x}/{y}.avif`. +/// Style metadata is stored as `{baseDir}/offline_tiles/{styleHash}/meta.json`. +/// This cache is separate from flutter_map's built-in cache and is used for +/// proactively downloaded tiles and WiFi sharing. +class OfflineTileCacheService { + OfflineTileCacheService._(); + static final instance = OfflineTileCacheService._(); + + String? _baseDir; + + Future get baseDir async { + if (_baseDir != null) return _baseDir!; + final docs = await getApplicationDocumentsDirectory(); + _baseDir = '${docs.path}/offline_tiles'; + return _baseDir!; + } + + /// Derive a short deterministic hash from a URL template. + String styleHashFromUrl(String urlTemplate) { + final bytes = sha256.convert(urlTemplate.codeUnits).bytes; + return bytes + .take(6) + .map((b) => b.toRadixString(16).padLeft(2, '0')) + .join(); + } + + String _tilePath(String base, String styleHash, int z, int x, int y) { + return '$base/$styleHash/$z/$x/$y.avif'; + } + + String _tileDir(String base, String styleHash, int z, int x) { + return '$base/$styleHash/$z/$x'; + } + + // In-memory manifest cache: styleHash → set of "z/x/y" keys. + // Loaded lazily, kept in sync with writes. + final Map> _manifests = {}; + + static String _tileKey(int z, int x, int y) => '$z/$x/$y'; + + String _manifestPath(String base, String styleHash) => + '$base/$styleHash/manifest.txt'; + + /// Load the manifest for a style into memory (if not already loaded). + Future> loadManifest(String styleHash) async { + if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!; + + final base = await baseDir; + final file = File(_manifestPath(base, styleHash)); + final Set manifest; + if (await file.exists()) { + final lines = await file.readAsLines(); + manifest = lines.where((l) => l.isNotEmpty).toSet(); + } else { + // First time — scan the filesystem and build the manifest + manifest = {}; + final styleDir = Directory('$base/$styleHash'); + if (await styleDir.exists()) { + final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$'); + await for (final entity in styleDir.list(recursive: true)) { + if (entity is! File) continue; + final match = avifPattern.firstMatch(entity.path); + if (match != null) { + manifest.add('${match.group(1)}/${match.group(2)}/${match.group(3)}'); + } + } + // Persist the scanned manifest + await _writeManifest(base, styleHash, manifest); + } + } + _manifests[styleHash] = manifest; + return manifest; + } + + Future _writeManifest( + String base, String styleHash, Set manifest) async { + final dir = Directory('$base/$styleHash'); + if (!await dir.exists()) await dir.create(recursive: true); + await File(_manifestPath(base, styleHash)) + .writeAsString(manifest.join('\n'), flush: true); + } + + /// Append a tile key to the manifest (both in-memory and on disk). + Future _addToManifest( + String base, String styleHash, String key) async { + _manifests[styleHash] ??= {}; + if (_manifests[styleHash]!.add(key)) { + final file = File(_manifestPath(base, styleHash)); + await file.writeAsString('$key\n', + mode: FileMode.append, flush: true); + } + } + + /// Check if a tile exists in the cache (uses in-memory manifest). + Future hasTile(String styleHash, int z, int x, int y) async { + final manifest = await loadManifest(styleHash); + return manifest.contains(_tileKey(z, x, y)); + } + + /// Read a cached tile's raw AVIF bytes (for serving to peers). + Future getRawTile(String styleHash, int z, int x, int y) async { + final base = await baseDir; + final file = File(_tilePath(base, styleHash, z, x, y)); + if (!await file.exists()) return null; + return file.readAsBytes(); + } + + /// Read a cached tile and decode AVIF → PNG bytes for flutter_map display. + Future getTileAsPng( + String styleHash, int z, int x, int y) async { + final avifBytes = await getRawTile(styleHash, z, x, y); + if (avifBytes == null) return null; + return _avifToPng(avifBytes); + } + + /// Store a tile: encode PNG bytes → AVIF, write to disk, update manifest. + Future putTile( + String styleHash, + int z, + int x, + int y, + Uint8List pngBytes, + ) async { + final base = await baseDir; + final dir = Directory(_tileDir(base, styleHash, z, x)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + final avifBytes = await _pngToAvif(pngBytes); + if (avifBytes == null) { + await File(_tilePath(base, styleHash, z, x, y)) + .writeAsBytes(pngBytes, flush: true); + } else { + await File(_tilePath(base, styleHash, z, x, y)) + .writeAsBytes(avifBytes, flush: true); + } + + await _addToManifest(base, styleHash, _tileKey(z, x, y)); + } + + /// Store raw AVIF bytes directly (from a peer), update manifest. + Future putRawTile( + String styleHash, + int z, + int x, + int y, + Uint8List avifBytes, + ) async { + final base = await baseDir; + final dir = Directory(_tileDir(base, styleHash, z, x)); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + await File(_tilePath(base, styleHash, z, x, y)) + .writeAsBytes(avifBytes, flush: true); + await _addToManifest(base, styleHash, _tileKey(z, x, y)); + } + + /// Get total cache size in bytes. + Future getCacheSize() async { + final base = await baseDir; + final dir = Directory(base); + if (!await dir.exists()) return 0; + + var totalSize = 0; + await for (final entity in dir.list(recursive: true)) { + if (entity is File) { + totalSize += await entity.length(); + } + } + return totalSize; + } + + /// List all style hashes that have cached tiles. + Future> listStyles() async { + final base = await baseDir; + final dir = Directory(base); + if (!await dir.exists()) return []; + + final styles = []; + await for (final entity in dir.list()) { + if (entity is Directory) { + styles.add(entity.path.split('/').last); + } + } + return styles; + } + + /// Save metadata for a style (name, URL template, download region). + Future saveStyleMeta( + String styleHash, { + required String displayName, + required String urlTemplate, + DownloadRegion? region, + }) async { + final base = await baseDir; + final dir = Directory('$base/$styleHash'); + if (!await dir.exists()) { + await dir.create(recursive: true); + } + + // Merge with existing meta to preserve region if not provided + final metaFile = File('$base/$styleHash/meta.json'); + Map meta = { + 'displayName': displayName, + 'urlTemplate': urlTemplate, + }; + if (region != null) { + meta['region'] = region.toJson(); + } else if (await metaFile.exists()) { + try { + final existing = jsonDecode(await metaFile.readAsString()); + if (existing['region'] != null) { + meta['region'] = existing['region']; + } + } catch (_) {} + } + + await metaFile.writeAsString(jsonEncode(meta), flush: true); + } + + /// Read metadata for a style. + Future getStyleMeta(String styleHash) async { + final base = await baseDir; + final metaFile = File('$base/$styleHash/meta.json'); + if (!await metaFile.exists()) return null; + try { + final json = jsonDecode(await metaFile.readAsString()); + return StyleInfo( + hash: styleHash, + displayName: json['displayName'] as String? ?? styleHash, + urlTemplate: json['urlTemplate'] as String? ?? '', + region: json['region'] != null + ? DownloadRegion.fromJson(json['region'] as Map) + : null, + ); + } catch (_) { + return null; + } + } + + /// List all styles with metadata, tile counts, and sizes. + Future> listStylesDetailed() async { + final base = await baseDir; + final dir = Directory(base); + if (!await dir.exists()) return []; + + final results = []; + await for (final entity in dir.list()) { + if (entity is! Directory) continue; + final hash = entity.path.split('/').last; + + // Read meta + String displayName = hash; + String urlTemplate = ''; + DownloadRegion? region; + final metaFile = File('${entity.path}/meta.json'); + if (await metaFile.exists()) { + try { + final json = jsonDecode(await metaFile.readAsString()); + displayName = json['displayName'] as String? ?? hash; + urlTemplate = json['urlTemplate'] as String? ?? ''; + if (json['region'] != null) { + region = DownloadRegion.fromJson( + json['region'] as Map); + } + } catch (_) {} + } + + // Count tiles and size + var tileCount = 0; + var sizeBytes = 0; + await for (final file in entity.list(recursive: true)) { + if (file is File && file.path.endsWith('.avif')) { + tileCount++; + sizeBytes += await file.length(); + } + } + + results.add(StyleInfo( + hash: hash, + displayName: displayName, + urlTemplate: urlTemplate, + tileCount: tileCount, + sizeBytes: sizeBytes, + region: region, + )); + } + return results; + } + + /// List all tile coordinates cached for a given style. + Future> listTilesForStyle(String styleHash) async { + final base = await baseDir; + final styleDir = Directory('$base/$styleHash'); + if (!await styleDir.exists()) return []; + + final tiles = []; + final avifPattern = RegExp(r'/(\d+)/(\d+)/(\d+)\.avif$'); + + await for (final entity in styleDir.list(recursive: true)) { + if (entity is! File) continue; + final match = avifPattern.firstMatch(entity.path); + if (match != null) { + tiles.add(CachedTileCoord( + int.parse(match.group(1)!), + int.parse(match.group(2)!), + int.parse(match.group(3)!), + )); + } + } + return tiles; + } + + /// Delete a single style's tiles, manifest, and metadata. + Future deleteStyle(String styleHash) async { + _manifests.remove(styleHash); + final base = await baseDir; + final dir = Directory('$base/$styleHash'); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + + /// Delete all cached tiles, manifests, and metadata. + Future clearCache() async { + _manifests.clear(); + final base = await baseDir; + final dir = Directory(base); + if (await dir.exists()) { + await dir.delete(recursive: true); + } + } + + /// Decode AVIF bytes to PNG (static helper for use by caching provider). + static Future getTileAsPngStatic(Uint8List avifBytes) { + return _avifToPng(avifBytes); + } + + /// Encode PNG → AVIF for tile storage. + /// Uses moderate quality for good compression with acceptable quality. + static Future _pngToAvif(Uint8List pngBytes) async { + try { + final avif = await encodeAvif( + pngBytes, + maxThreads: 2, + maxQuantizer: 40, // Good quality (0=lossless, 63=worst) + minQuantizer: 25, + maxQuantizerAlpha: 63, + minQuantizerAlpha: 63, + speed: 6, + keepExif: false, + ); + if (avif.isEmpty) return null; + return avif; + } catch (e) { + debugPrint('[OfflineTileCache] AVIF encode error: $e'); + return null; + } + } + + /// Decode AVIF → PNG bytes for display. + static Future _avifToPng(Uint8List avifBytes) async { + try { + // Use Flutter's image codec which can handle AVIF via flutter_avif + final codec = await ui.instantiateImageCodec(avifBytes); + final frame = await codec.getNextFrame(); + final image = frame.image; + final byteData = + await image.toByteData(format: ui.ImageByteFormat.png); + image.dispose(); + return byteData?.buffer.asUint8List(); + } catch (e) { + debugPrint('[OfflineTileCache] AVIF decode error: $e'); + return null; + } + } +} diff --git a/lib/services/path_history_service.dart b/lib/services/path_history_service.dart index a81cfaf..e2bca41 100644 --- a/lib/services/path_history_service.dart +++ b/lib/services/path_history_service.dart @@ -9,38 +9,72 @@ import '../models/path_history.dart'; import '../models/path_selection.dart'; import '../utils/log_rx_route_decoder.dart'; +class _ManualPathSelectionRecord { + final List pathBytes; + final int hopCount; + final int hashSize; + + const _ManualPathSelectionRecord({ + required this.pathBytes, + required this.hopCount, + required this.hashSize, + }); + + factory _ManualPathSelectionRecord.fromJson(Map json) { + final pathBytes = (json['pathBytes'] as List? ?? const []) + .whereType() + .toList(); + return _ManualPathSelectionRecord( + pathBytes: pathBytes, + hopCount: json['hopCount'] as int? ?? 0, + hashSize: json['hashSize'] as int? ?? 1, + ); + } + + Map toJson() => { + 'pathBytes': pathBytes, + 'hopCount': hopCount, + 'hashSize': hashSize, + }; + + PathSelection toSelection() => PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(pathBytes), + hopCount: hopCount, + hashSize: hashSize, + ); +} + class PathHistoryService { static const String _storageKey = 'contact_path_history_v2'; - static const String _suppressedRouteStorageKey = - 'contact_path_history_suppressed_routes_v1'; + static const String _manualRouteStorageKey = + 'contact_manual_path_overrides_v1'; static const int _maxDirectPaths = 20; static const int _topRotationCount = 3; final Map _cache = {}; - final Map _suppressedCurrentRoutes = {}; + final Map _manualSelections = {}; bool _isLoaded = false; Future initialize() async { if (_isLoaded) return; final prefs = await SharedPreferences.getInstance(); final raw = prefs.getString(_storageKey); - final suppressedRaw = prefs.getString(_suppressedRouteStorageKey); + final manualRaw = prefs.getString(_manualRouteStorageKey); if (raw == null || raw.isEmpty) { - if (suppressedRaw == null || suppressedRaw.isEmpty) { + if (manualRaw == null || manualRaw.isEmpty) { _isLoaded = true; return; } } try { - if (raw != null && raw.isNotEmpty) { - final decoded = jsonDecode(raw); - if (decoded is Map) { - for (final entry in decoded.entries) { - final value = entry.value; - if (value is Map) { - _cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); - } + final decoded = jsonDecode(raw); + if (decoded is Map) { + for (final entry in decoded.entries) { + final value = entry.value; + if (value is Map) { + _cache[entry.key] = ContactPathHistory.fromJson(entry.key, value); } } } @@ -48,61 +82,26 @@ class PathHistoryService { debugPrint('⚠️ [PathHistoryService] Failed to load history: $error'); } try { - if (suppressedRaw != null && suppressedRaw.isNotEmpty) { - final decoded = jsonDecode(suppressedRaw); + if (manualRaw != null && manualRaw.isNotEmpty) { + final decoded = jsonDecode(manualRaw); if (decoded is Map) { for (final entry in decoded.entries) { final value = entry.value; - if (value is String && value.isNotEmpty) { - _suppressedCurrentRoutes[entry.key] = value; + if (value is Map) { + _manualSelections[entry.key] = + _ManualPathSelectionRecord.fromJson(value); } } } } } catch (error) { debugPrint( - '⚠️ [PathHistoryService] Failed to load suppressed routes: $error', + '⚠️ [PathHistoryService] Failed to load manual routes: $error', ); } _isLoaded = true; } - Future recordLearnedPath(Contact contact) async { - await initialize(); - if (!contact.routeHasPath || contact.routeHopCount <= 0) { - return; - } - - final history = _historyFor(contact.publicKeyHex); - final signature = _signature(contact.routePathBytes); - if (_suppressedCurrentRoutes[contact.publicKeyHex] == signature) { - return; - } - final existing = _findDirectPath(history.directPaths, signature); - final updated = PathRecord( - pathBytes: contact.routePathBytes.toList(), - hopCount: contact.routeHopCount, - hashSize: contact.routeHashSize, - source: existing?.source ?? PathRecordSource.learned, - successCount: existing?.successCount ?? 0, - failureCount: existing?.failureCount ?? 0, - lastRoundTripTimeMs: existing?.lastRoundTripTimeMs ?? 0, - lastUsedAt: DateTime.now(), - lastSucceededAt: existing?.lastSucceededAt, - senderLatitude: existing?.senderLatitude, - senderLongitude: existing?.senderLongitude, - recipientLatitude: existing?.recipientLatitude, - recipientLongitude: existing?.recipientLongitude, - ); - - await _saveHistory( - contact.publicKeyHex, - history.copyWith( - directPaths: _upsertDirectPath(history.directPaths, updated), - ), - ); - } - Future recordReceivedBytePath( String contactPublicKeyHex, List pathBytes, @@ -128,10 +127,6 @@ class PathHistoryService { final signature = normalizedPathBytes .map((byte) => byte.toRadixString(16).padLeft(2, '0')) .join(); - _clearSuppressedRoute( - contactPublicKeyHex, - signature: signature, - ); final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( pathBytes: normalizedPathBytes, @@ -162,15 +157,9 @@ class PathHistoryService { required bool autoRouteRotationEnabled, }) async { await initialize(); - await recordLearnedPath(contact); - - if (contact.routeHasPath && contact.routeHopCount > 0) { - return PathSelection( - mode: PathSelectionMode.directCurrent, - pathBytes: Uint8List.fromList(contact.routePathBytes), - hopCount: contact.routeHopCount, - hashSize: contact.routeHashSize, - ); + final manualSelection = _manualSelections[contact.publicKeyHex]; + if (manualSelection != null) { + return manualSelection.toSelection(); } if (!autoRouteRotationEnabled) { @@ -241,10 +230,6 @@ class PathHistoryService { } final signature = _signature(selection.pathBytes); - _clearSuppressedRoute( - contactPublicKeyHex, - signature: signature, - ); final existing = _findDirectPath(history.directPaths, signature); final updated = PathRecord( pathBytes: selection.pathBytes.toList(), @@ -328,24 +313,53 @@ class PathHistoryService { ContactPathHistory.empty(contactPublicKeyHex); } + Future setManualRouteForContact( + Contact contact, + ParsedContactRoute route, + ) async { + await setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList(route.pathBytes), + hopCount: route.hopCount, + hashSize: route.hashSize, + ), + ); + } + + Future setManualSelectionFor( + String contactPublicKeyHex, + PathSelection selection, + ) async { + await initialize(); + _manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord( + pathBytes: selection.pathBytes.toList(), + hopCount: selection.hopCount, + hashSize: selection.hashSize, + ); + await _persistState(); + } + + Future getManualSelectionForContact(Contact contact) async { + await initialize(); + return _manualSelections[contact.publicKeyHex]?.toSelection(); + } + + Future clearManualRouteFor(String contactPublicKeyHex) async { + await initialize(); + _manualSelections.remove(contactPublicKeyHex); + await _persistState(); + } + Future clearHistoryFor(String contactPublicKeyHex) async { await initialize(); _cache.remove(contactPublicKeyHex); - _suppressedCurrentRoutes.remove(contactPublicKeyHex); await _persistState(); } Future clearHistoryForContact(Contact contact) async { - await initialize(); - _cache.remove(contact.publicKeyHex); - if (contact.routeHasPath && contact.routeHopCount > 0) { - _suppressedCurrentRoutes[contact.publicKeyHex] = _signature( - contact.routePathBytes, - ); - } else { - _suppressedCurrentRoutes.remove(contact.publicKeyHex); - } - await _persistState(); + await clearHistoryFor(contact.publicKeyHex); } ContactPathHistory _historyFor(String contactPublicKeyHex) { @@ -363,31 +377,18 @@ class PathHistoryService { await _persistState(); } - void _clearSuppressedRoute(String contactPublicKeyHex, {String? signature}) { - final suppressedSignature = _suppressedCurrentRoutes[contactPublicKeyHex]; - if (suppressedSignature == null) { - return; - } - if (signature == null || suppressedSignature == signature) { - _suppressedCurrentRoutes.remove(contactPublicKeyHex); - } - } - Future _persistState() async { final prefs = await SharedPreferences.getInstance(); final payload = {}; for (final entry in _cache.entries) { payload[entry.key] = entry.value.toJson(); } - final suppressedPayload = {}; - for (final entry in _suppressedCurrentRoutes.entries) { - suppressedPayload[entry.key] = entry.value; + final manualPayload = {}; + for (final entry in _manualSelections.entries) { + manualPayload[entry.key] = entry.value.toJson(); } await prefs.setString(_storageKey, jsonEncode(payload)); - await prefs.setString( - _suppressedRouteStorageKey, - jsonEncode(suppressedPayload), - ); + await prefs.setString(_manualRouteStorageKey, jsonEncode(manualPayload)); } List _upsertDirectPath( diff --git a/lib/services/tile_download_service.dart b/lib/services/tile_download_service.dart new file mode 100644 index 0000000..e6f2dba --- /dev/null +++ b/lib/services/tile_download_service.dart @@ -0,0 +1,332 @@ +import 'dart:async'; + +import 'package:http/http.dart' as http; +import 'package:latlong2/latlong.dart'; + +import 'offline_tile_cache_service.dart'; +import 'tile_math_service.dart'; + +/// Events emitted during tile download. +sealed class TileDownloadEvent {} + +class TileDownloadStarted extends TileDownloadEvent { + final int totalTiles; + TileDownloadStarted(this.totalTiles); +} + +class TileDownloaded extends TileDownloadEvent { + final double north, south, east, west; + TileDownloaded({ + required this.north, + required this.south, + required this.east, + required this.west, + }); +} + +class TileSkipped extends TileDownloadEvent { + final double north, south, east, west; + TileSkipped({ + required this.north, + required this.south, + required this.east, + required this.west, + }); +} + +class TileFailed extends TileDownloadEvent { + final TileCoord coord; + final String error; + TileFailed(this.coord, this.error); +} + +class TileDownloadComplete extends TileDownloadEvent { + final int downloaded; + final int skipped; + final int failed; + final int total; + TileDownloadComplete({ + required this.downloaded, + required this.skipped, + required this.failed, + required this.total, + }); +} + +class TileBatchSkipped extends TileDownloadEvent { + final int count; + final int total; + TileBatchSkipped({required this.count, required this.total}); +} + +class TileDownloadCancelled extends TileDownloadEvent {} + +/// Downloads map tiles for given polygons and zoom levels. +class TileDownloadService { + final OfflineTileCacheService _cache = OfflineTileCacheService.instance; + final http.Client _httpClient = http.Client(); + + bool _cancelled = false; + + /// Cancel an ongoing download. + void cancel() { + _cancelled = true; + } + + /// Download tiles for the given polygons and zoom range. + /// + /// Returns a stream of [TileDownloadEvent]s. + /// [urlTemplate] should contain `{z}`, `{x}`, `{y}` placeholders, + /// and optionally `{s}` for subdomains. + Stream downloadTiles({ + required List> polygons, + required int minZoom, + required int maxZoom, + required String urlTemplate, + String? displayName, + int maxConcurrency = 6, + int rateLimit = 30, + }) { + final controller = StreamController(); + + _runDownload( + controller: controller, + polygons: polygons, + minZoom: minZoom, + maxZoom: maxZoom, + urlTemplate: urlTemplate, + displayName: displayName, + maxConcurrency: maxConcurrency, + rateLimit: rateLimit, + ); + + return controller.stream; + } + + Future _runDownload({ + required StreamController controller, + required List> polygons, + required int minZoom, + required int maxZoom, + required String urlTemplate, + String? displayName, + required int maxConcurrency, + required int rateLimit, + }) async { + _cancelled = false; + + final styleHash = _cache.styleHashFromUrl(urlTemplate); + + // Save style metadata with download region so it can be reused + final region = DownloadRegion( + polygons: polygons + .map((poly) => + poly.map((p) => [p.latitude, p.longitude]).toList()) + .toList(), + minZoom: minZoom, + maxZoom: maxZoom, + ); + await _cache.saveStyleMeta( + styleHash, + displayName: displayName ?? urlTemplate, + urlTemplate: urlTemplate, + region: region, + ); + + final allTiles = + TileMathService.getTilesForPolygons(polygons, minZoom, maxZoom); + + // Load manifest once and partition tiles into needed vs already cached + final manifest = await _cache.loadManifest(styleHash); + final tilesToDownload = []; + var skipped = 0; + + for (final tile in allTiles) { + final key = '${tile.z}/${tile.x}/${tile.y}'; + if (manifest.contains(key)) { + skipped++; + } else { + tilesToDownload.add(tile); + } + } + + final total = allTiles.length; + controller.add(TileDownloadStarted(total)); + + // Report all skipped tiles immediately (no per-tile filesystem check) + if (skipped > 0) { + controller.add(TileBatchSkipped(count: skipped, total: total)); + } + + if (tilesToDownload.isEmpty) { + controller.add(TileDownloadComplete( + downloaded: 0, skipped: skipped, failed: 0, total: total)); + await controller.close(); + return; + } + + var downloaded = 0; + var failed = 0; + + final semaphore = _Semaphore(maxConcurrency); + final rateLimiter = _RateLimiter(rateLimit); + final futures = >[]; + + for (final tile in tilesToDownload) { + if (_cancelled) break; + + await rateLimiter.wait(); + if (_cancelled) break; + + await semaphore.acquire(); + if (_cancelled) { + semaphore.release(); + break; + } + + final future = _downloadSingleTile(tile, urlTemplate, styleHash) + .then((event) { + if (!controller.isClosed) { + controller.add(event); + if (event is TileDownloaded) { + downloaded++; + } else if (event is TileFailed) { + failed++; + } + } + semaphore.release(); + }); + futures.add(future); + } + + // Wait for all in-flight downloads to finish + await Future.wait(futures); + + if (_cancelled) { + controller.add(TileDownloadCancelled()); + } else { + controller.add(TileDownloadComplete( + downloaded: downloaded, + skipped: skipped, + failed: failed, + total: total, + )); + } + + await controller.close(); + } + + Future _downloadSingleTile( + TileCoord tile, + String urlTemplate, + String styleHash, + ) async { + final bounds = TileMathService.tileBounds(tile.x, tile.y, tile.z); + + // Build URL + final subdomains = ['a', 'b', 'c']; + var url = urlTemplate + .replaceAll('{s}', subdomains[tile.x % 3]) + .replaceAll('{z}', '${tile.z}') + .replaceAll('{x}', '${tile.x}') + .replaceAll('{y}', '${tile.y}'); + + // Download with retries + const maxRetries = 3; + for (var attempt = 0; attempt < maxRetries; attempt++) { + if (_cancelled) { + return TileFailed(tile, 'Cancelled'); + } + + try { + final response = await _httpClient.get( + Uri.parse(url), + headers: {'User-Agent': 'MeshCoreSAR/1.0'}, + ); + + if (response.statusCode != 200) { + if (attempt < maxRetries - 1) { + await Future.delayed(Duration(seconds: 1 << attempt)); + continue; + } + return TileFailed(tile, 'HTTP ${response.statusCode}'); + } + + // Store tile (PNG → AVIF conversion happens inside cache service) + await _cache.putTile( + styleHash, tile.z, tile.x, tile.y, response.bodyBytes); + + return TileDownloaded( + north: bounds.north, + south: bounds.south, + east: bounds.east, + west: bounds.west, + ); + } catch (e) { + if (attempt < maxRetries - 1) { + await Future.delayed(Duration(seconds: 1 << attempt)); + continue; + } + return TileFailed(tile, e.toString()); + } + } + + return TileFailed(tile, 'Max retries exceeded'); + } + + void dispose() { + _cancelled = true; + _httpClient.close(); + } +} + +/// Simple counting semaphore for concurrency limiting. +class _Semaphore { + final int maxCount; + int _currentCount = 0; + final _waitQueue = >[]; + + _Semaphore(this.maxCount); + + Future acquire() async { + if (_currentCount < maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waitQueue.add(completer); + await completer.future; + } + + void release() { + if (_waitQueue.isNotEmpty) { + _waitQueue.removeAt(0).complete(); + } else { + _currentCount--; + } + } +} + +/// Rate limiter that ensures no more than [maxPerSecond] operations per second. +class _RateLimiter { + final int maxPerSecond; + final _timestamps = []; + + _RateLimiter(this.maxPerSecond); + + Future wait() async { + final now = DateTime.now(); + _timestamps + .removeWhere((t) => now.difference(t) > const Duration(seconds: 1)); + + if (_timestamps.length >= maxPerSecond) { + final oldest = _timestamps.first; + final waitTime = const Duration(seconds: 1) - now.difference(oldest); + if (waitTime > Duration.zero) { + await Future.delayed(waitTime); + } + _timestamps.removeAt(0); + } + _timestamps.add(DateTime.now()); + } +} diff --git a/lib/services/tile_math_service.dart b/lib/services/tile_math_service.dart new file mode 100644 index 0000000..20f6707 --- /dev/null +++ b/lib/services/tile_math_service.dart @@ -0,0 +1,268 @@ +import 'dart:math'; + +import 'package:latlong2/latlong.dart'; + +/// A tile coordinate with x, y, and zoom level. +class TileCoord { + final int x; + final int y; + final int z; + + const TileCoord(this.x, this.y, this.z); + + @override + bool operator ==(Object other) => + other is TileCoord && other.x == x && other.y == y && other.z == z; + + @override + int get hashCode => Object.hash(x, y, z); + + @override + String toString() => 'TileCoord($z/$x/$y)'; +} + +/// A geographical bounding box. +class TileBounds { + final double north; + final double south; + final double east; + final double west; + + const TileBounds({ + required this.north, + required this.south, + required this.east, + required this.west, + }); +} + +/// Pure math utilities for slippy map tile calculations. +/// +/// Ported from the Go offline-map-tile-downloader. +class TileMathService { + const TileMathService._(); + + /// Convert latitude/longitude to tile coordinates at the given zoom level. + static (int x, int y) latLonToTile(double lat, double lon, int zoom) { + final latRad = lat * pi / 180; + final n = pow(2, zoom).toDouble(); + final x = (n * ((lon + 180) / 360)).floor(); + final y = + (n * (1 - (log(tan(latRad) + 1 / cos(latRad)) / pi)) / 2).floor(); + return (x, y); + } + + /// Calculate the geographical bounding box of a tile. + static TileBounds tileBounds(int x, int y, int z) { + final n = pow(2.0, z).toDouble(); + final lonDeg = x / n * 360.0 - 180.0; + final latRad = atan(sinh(pi * (1 - 2 * y / n))); + final latDeg = latRad * 180.0 / pi; + + final lon2Deg = (x + 1) / n * 360.0 - 180.0; + final lat2Rad = atan(sinh(pi * (1 - 2 * (y + 1) / n))); + final lat2Deg = lat2Rad * 180.0 / pi; + + return TileBounds( + north: latDeg, + south: lat2Deg, + east: lon2Deg, + west: lonDeg, + ); + } + + /// Hyperbolic sine. + static double sinh(double x) => (exp(x) - exp(-x)) / 2; + + /// Check if a point is inside a polygon using the ray casting algorithm. + static bool polygonContains(List poly, LatLng point) { + var inside = false; + for (int i = 0, j = poly.length - 1; i < poly.length; j = i++) { + if ((poly[i].latitude > point.latitude) != + (poly[j].latitude > point.latitude) && + (point.longitude < + (poly[j].longitude - poly[i].longitude) * + (point.latitude - poly[i].latitude) / + (poly[j].latitude - poly[i].latitude) + + poly[i].longitude)) { + inside = !inside; + } + } + return inside; + } + + /// Check if a bounding box contains a point. + static bool boundsContains(TileBounds bounds, LatLng point) { + return point.latitude <= bounds.north && + point.latitude >= bounds.south && + point.longitude >= bounds.west && + point.longitude <= bounds.east; + } + + /// Check if a polygon intersects with a tile bounding box. + static bool polygonIntersects(List poly, TileBounds bounds) { + // Check if any polygon vertex is inside the tile + for (final p in poly) { + if (boundsContains(bounds, p)) return true; + } + + // Check if any tile corner is inside the polygon + final corners = [ + LatLng(bounds.north, bounds.west), + LatLng(bounds.north, bounds.east), + LatLng(bounds.south, bounds.west), + LatLng(bounds.south, bounds.east), + ]; + for (final corner in corners) { + if (polygonContains(poly, corner)) return true; + } + + // Check if any polygon edge intersects any tile edge + final tileEdges = [ + (corners[0], corners[1]), + (corners[1], corners[3]), + (corners[3], corners[2]), + (corners[2], corners[0]), + ]; + for (int i = 0; i < poly.length; i++) { + final p1 = poly[i]; + final p2 = poly[(i + 1) % poly.length]; + for (final edge in tileEdges) { + if (_lineIntersects(p1, p2, edge.$1, edge.$2)) return true; + } + } + + return false; + } + + /// Check if two line segments intersect. + static bool _lineIntersects(LatLng p1, LatLng q1, LatLng p2, LatLng q2) { + final o1 = _orientation(p1, q1, p2); + final o2 = _orientation(p1, q1, q2); + final o3 = _orientation(p2, q2, p1); + final o4 = _orientation(p2, q2, q1); + + if (o1 != o2 && o3 != o4) return true; + + if (o1 == 0 && _onSegment(p1, p2, q1)) return true; + if (o2 == 0 && _onSegment(p1, q2, q1)) return true; + if (o3 == 0 && _onSegment(p2, p1, q2)) return true; + if (o4 == 0 && _onSegment(p2, q1, q2)) return true; + + return false; + } + + /// Find orientation of ordered triplet (p, q, r). + /// Returns 0 for collinear, 1 for clockwise, 2 for counterclockwise. + static int _orientation(LatLng p, LatLng q, LatLng r) { + final val = (q.longitude - p.longitude) * (r.latitude - q.latitude) - + (q.latitude - p.latitude) * (r.longitude - q.longitude); + if (val == 0) return 0; + return val > 0 ? 1 : 2; + } + + /// Check if point q lies on segment pr. + static bool _onSegment(LatLng p, LatLng q, LatLng r) { + return q.latitude <= max(p.latitude, r.latitude) && + q.latitude >= min(p.latitude, r.latitude) && + q.longitude <= max(p.longitude, r.longitude) && + q.longitude >= min(p.longitude, r.longitude); + } + + /// Get all tiles that overlap with the given polygons across zoom levels. + static List getTilesForPolygons( + List> polygons, + int minZoom, + int maxZoom, + ) { + final tileSet = {}; + + for (final poly in polygons) { + if (poly.length < 3) continue; + + // Find bounding box of polygon + var minLat = 90.0, minLon = 180.0; + var maxLat = -90.0, maxLon = -180.0; + for (final p in poly) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + + for (int z = minZoom; z <= maxZoom; z++) { + final (tlx, tly) = latLonToTile(maxLat, minLon, z); + final (brx, bry) = latLonToTile(minLat, maxLon, z); + + for (int x = tlx; x <= brx; x++) { + for (int y = tly; y <= bry; y++) { + final tile = TileCoord(x, y, z); + if (tileSet.contains(tile)) continue; + + final bounds = tileBounds(x, y, z); + + // Check if all tile corners are inside the polygon + final allCornersInside = polygonContains( + poly, LatLng(bounds.north, bounds.west)) && + polygonContains(poly, LatLng(bounds.north, bounds.east)) && + polygonContains(poly, LatLng(bounds.south, bounds.west)) && + polygonContains(poly, LatLng(bounds.south, bounds.east)); + if (allCornersInside) { + tileSet.add(tile); + continue; + } + + // Check if all polygon vertices are inside the tile + var polyInTile = true; + for (final p in poly) { + if (!boundsContains(bounds, p)) { + polyInTile = false; + break; + } + } + if (polyInTile) { + tileSet.add(tile); + continue; + } + + // Check for intersection + if (polygonIntersects(poly, bounds)) { + tileSet.add(tile); + } + } + } + } + } + + return tileSet.toList(); + } + + /// Estimate the number of tiles for given polygons and zoom range. + /// Faster than getTilesForPolygons — uses bounding box approximation. + static int estimateTileCount( + List> polygons, + int minZoom, + int maxZoom, + ) { + var count = 0; + for (final poly in polygons) { + if (poly.length < 3) continue; + + var minLat = 90.0, minLon = 180.0; + var maxLat = -90.0, maxLon = -180.0; + for (final p in poly) { + if (p.latitude < minLat) minLat = p.latitude; + if (p.latitude > maxLat) maxLat = p.latitude; + if (p.longitude < minLon) minLon = p.longitude; + if (p.longitude > maxLon) maxLon = p.longitude; + } + + for (int z = minZoom; z <= maxZoom; z++) { + final (tlx, tly) = latLonToTile(maxLat, minLon, z); + final (brx, bry) = latLonToTile(minLat, maxLon, z); + count += (brx - tlx + 1) * (bry - tly + 1); + } + } + return count; + } +} diff --git a/lib/services/tile_sharing_service.dart b/lib/services/tile_sharing_service.dart new file mode 100644 index 0000000..07fe41f --- /dev/null +++ b/lib/services/tile_sharing_service.dart @@ -0,0 +1,518 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:nsd/nsd.dart' as nsd; + +import 'offline_tile_cache_service.dart'; + +/// A discovered tile-serving peer on the local network. +class TilePeer { + final String ipAddress; + final int port; + + const TilePeer({required this.ipAddress, required this.port}); + + String get baseUrl => 'http://$ipAddress:$port'; + + @override + bool operator ==(Object other) => + other is TilePeer && other.ipAddress == ipAddress && other.port == port; + + @override + int get hashCode => Object.hash(ipAddress, port); + + @override + String toString() => 'TilePeer($ipAddress:$port)'; +} + +/// What a remote peer has available. +class PeerCatalog { + final TilePeer peer; + final List styles; + + const PeerCatalog({required this.peer, required this.styles}); +} + +/// Progress events during a P2P sync. +sealed class PeerSyncEvent {} + +class PeerSyncStarted extends PeerSyncEvent { + final int totalTiles; + PeerSyncStarted(this.totalTiles); +} + +class PeerSyncTileDownloaded extends PeerSyncEvent { + final int downloaded; + final int total; + PeerSyncTileDownloaded({required this.downloaded, required this.total}); +} + +class PeerSyncTileSkipped extends PeerSyncEvent { + final int skipped; + final int total; + PeerSyncTileSkipped({required this.skipped, required this.total}); +} + +class PeerSyncComplete extends PeerSyncEvent { + final int downloaded; + final int skipped; + final int failed; + PeerSyncComplete({ + required this.downloaded, + required this.skipped, + required this.failed, + }); +} + +class PeerSyncCancelled extends PeerSyncEvent {} + +/// HTTP server that serves cached AVIF tiles to other devices on the +/// local network, with mDNS advertisement, peer discovery, and P2P sync. +/// +/// Protocol: +/// GET /styles → JSON array of StyleInfo +/// GET /tiles/{hash}/list → JSON array of {z, x, y} +/// GET /tiles/{hash}/{z}/{x}/{y}.avif → AVIF bytes | 404 +class TileSharingService { + TileSharingService._(); + static final instance = TileSharingService._(); + + static const int defaultPort = 8347; + static const String serviceType = '_sartiles._tcp'; + + final OfflineTileCacheService _cache = OfflineTileCacheService.instance; + final http.Client _httpClient = http.Client(); + + HttpServer? _server; + nsd.Discovery? _activeDiscovery; + nsd.Registration? _activeRegistration; + + final _peersController = StreamController>.broadcast(); + final Set _discoveredPeers = {}; + + bool _syncCancelled = false; + + bool get isRunning => _server != null; + Stream> get peersStream => _peersController.stream; + Set get discoveredPeers => Set.unmodifiable(_discoveredPeers); + + // ── Server ────────────────────────────────────────────────────────────── + + Future startServer() async { + if (_server != null) return; + + try { + _server = await HttpServer.bind(InternetAddress.anyIPv4, defaultPort); + debugPrint('[TileSharing] Server started on port $defaultPort'); + + _server!.listen(_handleRequest, onError: (error) { + debugPrint('[TileSharing] Server error: $error'); + }); + + await _advertise(); + } catch (e) { + debugPrint('[TileSharing] Failed to start server: $e'); + _server = null; + } + } + + Future stopServer() async { + await _stopAdvertising(); + await _server?.close(); + _server = null; + debugPrint('[TileSharing] Server stopped'); + } + + // ── Discovery ─────────────────────────────────────────────────────────── + + Future startDiscovery() async { + if (_activeDiscovery != null) return; + + try { + _activeDiscovery = await nsd.startDiscovery(serviceType); + _activeDiscovery!.addServiceListener((service, status) { + if (service.host == null || service.port == null) return; + + final peer = TilePeer( + ipAddress: service.host!, + port: service.port!, + ); + + if (status == nsd.ServiceStatus.found) { + _discoveredPeers.add(peer); + } else { + _discoveredPeers.remove(peer); + } + _peersController.add(Set.unmodifiable(_discoveredPeers)); + }); + } catch (e) { + debugPrint('[TileSharing] Discovery error: $e'); + } + } + + Future stopPeerDiscovery() async { + if (_activeDiscovery != null) { + await nsd.stopDiscovery(_activeDiscovery!); + _activeDiscovery = null; + } + _discoveredPeers.clear(); + _peersController.add(const {}); + } + + void addManualPeer(String ipAddress, {int port = defaultPort}) { + _discoveredPeers.add(TilePeer(ipAddress: ipAddress, port: port)); + _peersController.add(Set.unmodifiable(_discoveredPeers)); + } + + void removePeer(TilePeer peer) { + _discoveredPeers.remove(peer); + _peersController.add(Set.unmodifiable(_discoveredPeers)); + } + + // ── Peer queries ──────────────────────────────────────────────────────── + + /// Fetch the catalog (available styles + tile counts) from a peer. + Future fetchPeerCatalog(TilePeer peer) async { + try { + final uri = Uri.parse('${peer.baseUrl}/styles'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode != 200) return null; + + final List data = jsonDecode(response.body); + final styles = data + .map((e) => StyleInfo.fromJson(e as Map)) + .toList(); + return PeerCatalog(peer: peer, styles: styles); + } catch (e) { + debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e'); + return null; + } + } + + /// Fetch catalogs from all discovered peers. + Future> fetchAllPeerCatalogs() async { + final futures = + _discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList(); + final results = await Future.wait(futures); + return results.whereType().toList(); + } + + /// Fetch the tile list for a style from a peer. + Future?> fetchPeerTileList( + TilePeer peer, + String styleHash, + ) async { + try { + final uri = Uri.parse('${peer.baseUrl}/tiles/$styleHash/list'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 10)); + if (response.statusCode != 200) return null; + + final List data = jsonDecode(response.body); + return data + .map((e) => CachedTileCoord.fromJson(e as Map)) + .toList(); + } catch (e) { + debugPrint('[TileSharing] fetchPeerTileList(${peer.ipAddress}): $e'); + return null; + } + } + + /// Fetch a single tile from a peer. Returns raw AVIF bytes or null. + Future fetchTileFromPeer( + TilePeer peer, + String styleHash, + int z, + int x, + int y, + ) async { + try { + final uri = + Uri.parse('${peer.baseUrl}/tiles/$styleHash/$z/$x/$y.avif'); + final response = + await _httpClient.get(uri).timeout(const Duration(seconds: 5)); + if (response.statusCode == 200) return response.bodyBytes; + } catch (e) { + // Silently fail — caller will try next peer + } + return null; + } + + /// Try fetching a tile from any available peer (for the caching provider). + Future fetchFromAnyPeer( + String styleHash, + int z, + int x, + int y, + ) async { + for (final peer in _discoveredPeers) { + final bytes = await fetchTileFromPeer(peer, styleHash, z, x, y); + if (bytes != null) return bytes; + } + return null; + } + + // ── P2P Sync ──────────────────────────────────────────────────────────── + + void cancelSync() { + _syncCancelled = true; + } + + /// Sync a style from peers: fetch their tile list, download tiles we + /// don't have, trying multiple peers in round-robin for speed. + /// + /// [peers] — which peers to pull from (all that have this style). + /// [styleHash] — which style to sync. + /// [styleMeta] — metadata to save locally (name, URL template). + Stream syncStyleFromPeers({ + required List peers, + required String styleHash, + required StyleInfo styleMeta, + int maxConcurrency = 8, + }) { + final controller = StreamController(); + _runSync( + controller: controller, + peers: peers, + styleHash: styleHash, + styleMeta: styleMeta, + maxConcurrency: maxConcurrency, + ); + return controller.stream; + } + + Future _runSync({ + required StreamController controller, + required List peers, + required String styleHash, + required StyleInfo styleMeta, + required int maxConcurrency, + }) async { + _syncCancelled = false; + + // Save style metadata locally + await _cache.saveStyleMeta( + styleHash, + displayName: styleMeta.displayName, + urlTemplate: styleMeta.urlTemplate, + ); + + // Collect tile lists from all peers and merge (union) + final allTiles = {}; + for (final peer in peers) { + if (_syncCancelled) break; + final tiles = await fetchPeerTileList(peer, styleHash); + if (tiles != null) { + for (final t in tiles) { + allTiles['${t.z}/${t.x}/${t.y}'] = t; + } + } + } + + final tilesToSync = allTiles.values.toList(); + controller.add(PeerSyncStarted(tilesToSync.length)); + + if (tilesToSync.isEmpty || _syncCancelled) { + controller + .add(PeerSyncComplete(downloaded: 0, skipped: 0, failed: 0)); + await controller.close(); + return; + } + + var downloaded = 0; + var skipped = 0; + var failed = 0; + final total = tilesToSync.length; + + final semaphore = _Semaphore(maxConcurrency); + final futures = >[]; + var peerIndex = 0; + + for (final tile in tilesToSync) { + if (_syncCancelled) break; + + await semaphore.acquire(); + if (_syncCancelled) { + semaphore.release(); + break; + } + + // Round-robin across peers for parallel throughput + final peer = peers[peerIndex % peers.length]; + peerIndex++; + + final future = () async { + try { + // Skip if we already have it + if (await _cache.hasTile(styleHash, tile.z, tile.x, tile.y)) { + skipped++; + controller.add( + PeerSyncTileSkipped(skipped: skipped, total: total)); + return; + } + + // Try this peer, then fallback to others + Uint8List? bytes = + await fetchTileFromPeer(peer, styleHash, tile.z, tile.x, tile.y); + if (bytes == null) { + for (final fallback in peers) { + if (fallback == peer) continue; + bytes = await fetchTileFromPeer( + fallback, styleHash, tile.z, tile.x, tile.y); + if (bytes != null) break; + } + } + + if (bytes != null) { + await _cache.putRawTile( + styleHash, tile.z, tile.x, tile.y, bytes); + downloaded++; + controller.add(PeerSyncTileDownloaded( + downloaded: downloaded, total: total)); + } else { + failed++; + } + } catch (_) { + failed++; + } finally { + semaphore.release(); + } + }(); + futures.add(future); + } + + await Future.wait(futures); + + if (_syncCancelled) { + controller.add(PeerSyncCancelled()); + } else { + controller.add(PeerSyncComplete( + downloaded: downloaded, + skipped: skipped, + failed: failed, + )); + } + await controller.close(); + } + + // ── mDNS ──────────────────────────────────────────────────────────────── + + Future _advertise() async { + try { + final styles = await _cache.listStyles(); + _activeRegistration = await nsd.register(nsd.Service( + name: 'MeshCore SAR Tiles', + type: serviceType, + port: defaultPort, + txt: { + 'styles': + Uint8List.fromList(utf8.encode(styles.join(','))), + }, + )); + } catch (e) { + debugPrint('[TileSharing] mDNS registration error: $e'); + } + } + + Future _stopAdvertising() async { + if (_activeRegistration != null) { + await nsd.unregister(_activeRegistration!); + _activeRegistration = null; + } + } + + // ── HTTP Server ───────────────────────────────────────────────────────── + + void _handleRequest(HttpRequest request) async { + request.response.headers.add('Access-Control-Allow-Origin', '*'); + + final path = request.uri.path; + + // GET /styles → detailed style list + if (path == '/styles') { + final styles = await _cache.listStylesDetailed(); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode(styles.map((s) => s.toJson()).toList())); + await request.response.close(); + return; + } + + // GET /tiles/{hash}/list → tile coordinate inventory + final listPattern = RegExp(r'^/tiles/([a-f0-9]+)/list$'); + final listMatch = listPattern.firstMatch(path); + if (listMatch != null) { + final styleHash = listMatch.group(1)!; + final tiles = await _cache.listTilesForStyle(styleHash); + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType.json + ..write(jsonEncode(tiles.map((t) => t.toJson()).toList())); + await request.response.close(); + return; + } + + // GET /tiles/{hash}/{z}/{x}/{y}.avif → tile bytes + final tilePattern = + RegExp(r'^/tiles/([a-f0-9]+)/(\d+)/(\d+)/(\d+)\.avif$'); + final tileMatch = tilePattern.firstMatch(path); + if (tileMatch != null) { + final styleHash = tileMatch.group(1)!; + final z = int.parse(tileMatch.group(2)!); + final x = int.parse(tileMatch.group(3)!); + final y = int.parse(tileMatch.group(4)!); + + final bytes = await _cache.getRawTile(styleHash, z, x, y); + if (bytes != null) { + request.response + ..statusCode = HttpStatus.ok + ..headers.contentType = ContentType('image', 'avif') + ..add(bytes); + await request.response.close(); + return; + } + } + + request.response.statusCode = HttpStatus.notFound; + await request.response.close(); + } + + void dispose() { + stopServer(); + stopPeerDiscovery(); + _httpClient.close(); + _peersController.close(); + } +} + +/// Simple counting semaphore for concurrency limiting. +class _Semaphore { + final int maxCount; + int _currentCount = 0; + final _waitQueue = >[]; + + _Semaphore(this.maxCount); + + Future acquire() async { + if (_currentCount < maxCount) { + _currentCount++; + return; + } + final completer = Completer(); + _waitQueue.add(completer); + await completer.future; + } + + void release() { + if (_waitQueue.isNotEmpty) { + _waitQueue.removeAt(0).complete(); + } else { + _currentCount--; + } + } +} diff --git a/lib/widgets/contacts/contact_tile.dart b/lib/widgets/contacts/contact_tile.dart index ba2185d..50564ec 100644 --- a/lib/widgets/contacts/contact_tile.dart +++ b/lib/widgets/contacts/contact_tile.dart @@ -825,6 +825,7 @@ class ContactTile extends StatelessWidget { return; } + await pathHistoryService.clearManualRouteFor(contact.publicKeyHex); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( @@ -855,6 +856,7 @@ class ContactTile extends StatelessWidget { outPath: Uint8List.fromList(parsedRoute.paddedPathBytes), ), ); + await pathHistoryService.setManualRouteForContact(contact, parsedRoute); if (context.mounted) { final routeLabel = parsedRoute.hopCount == 0 ? AppLocalizations.of(context)!.direct diff --git a/lib/widgets/map/polygon_draw_handler.dart b/lib/widgets/map/polygon_draw_handler.dart new file mode 100644 index 0000000..8cd8916 --- /dev/null +++ b/lib/widgets/map/polygon_draw_handler.dart @@ -0,0 +1,303 @@ +import 'dart:math' show log, ln2; + +import 'package:flutter/material.dart'; +import 'package:flutter_map/flutter_map.dart'; +import 'package:latlong2/latlong.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/offline_tiles_provider.dart'; + +/// Renders polygon/rectangle drawing interaction on the map. +/// +/// Shows completed polygons, in-progress vertices, and handles tap events. +class PolygonDrawLayer extends StatelessWidget { + const PolygonDrawLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + final polygons = []; + final markers = []; + + // Completed polygons + for (int i = 0; i < provider.polygons.length; i++) { + final poly = provider.polygons[i]; + polygons.add(Polygon( + points: poly, + color: Colors.blue.withValues(alpha: 0.2), + borderColor: Colors.blue, + borderStrokeWidth: 2, + )); + } + + // In-progress polygon vertices + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.isNotEmpty) { + final verts = provider.currentVertices; + + // Draw lines between vertices + if (verts.length >= 2) { + polygons.add(Polygon( + points: verts, + color: Colors.orange.withValues(alpha: 0.1), + borderColor: Colors.orange, + borderStrokeWidth: 2, + )); + } + + // Draw vertex markers + for (final v in verts) { + markers.add(Marker( + point: v, + width: 12, + height: 12, + child: Container( + decoration: BoxDecoration( + color: Colors.orange, + shape: BoxShape.circle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + )); + } + } + + // Rectangle first corner marker + if (provider.drawingMode == DrawingMode.rectangle && + provider.rectangleFirstCorner != null) { + markers.add(Marker( + point: provider.rectangleFirstCorner!, + width: 14, + height: 14, + child: Container( + decoration: BoxDecoration( + color: Colors.orange, + shape: BoxShape.rectangle, + border: Border.all(color: Colors.white, width: 2), + ), + ), + )); + } + + return Stack( + children: [ + if (polygons.isNotEmpty) PolygonLayer(polygons: polygons), + if (markers.isNotEmpty) MarkerLayer(markers: markers), + ], + ); + }, + ); + } +} + +/// Renders the download progress overlay tiles. +class DownloadProgressLayer extends StatelessWidget { + const DownloadProgressLayer({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.tileOverlays.isEmpty) return const SizedBox.shrink(); + + final polygons = provider.tileOverlays.map((overlay) { + return Polygon( + points: [ + LatLng(overlay.north, overlay.west), + LatLng(overlay.north, overlay.east), + LatLng(overlay.south, overlay.east), + LatLng(overlay.south, overlay.west), + ], + color: overlay.isSkipped + ? Colors.green.withValues(alpha: 0.15) + : Colors.orange.withValues(alpha: 0.15), + borderColor: + overlay.isSkipped ? Colors.green : Colors.orange, + borderStrokeWidth: 1, + ); + }).toList(); + + return PolygonLayer(polygons: polygons); + }, + ); + } +} + +/// Renders the coverage overlay for a cached style. +/// +/// Only renders tiles at the zoom level closest to the current map zoom +/// to avoid drawing thousands of rectangles at once. +class CoverageLayer extends StatelessWidget { + final double currentZoom; + + const CoverageLayer({super.key, required this.currentZoom}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.coverageOverlays.isEmpty) { + return const SizedBox.shrink(); + } + + // Filter to tiles near the current zoom level to keep rendering fast. + // Show the zoom level that's <= current map zoom (best visual match). + final targetZoom = currentZoom.floor(); + + // Group overlays by approximate zoom level based on tile size. + // A tile at zoom z covers roughly (360/2^z) degrees of longitude. + // We filter by checking if the tile width matches the target zoom. + final filtered = []; + for (final overlay in provider.coverageOverlays) { + // Estimate the zoom level from the tile's longitude span + final lonSpan = (overlay.east - overlay.west).abs(); + if (lonSpan <= 0) continue; + final estimatedZoom = (log(360.0 / lonSpan) / ln2).round(); + + if (estimatedZoom == targetZoom || + estimatedZoom == targetZoom - 1 || + estimatedZoom == targetZoom + 1) { + filtered.add(Polygon( + points: [ + LatLng(overlay.north, overlay.west), + LatLng(overlay.north, overlay.east), + LatLng(overlay.south, overlay.east), + LatLng(overlay.south, overlay.west), + ], + color: Colors.blue.withValues(alpha: 0.1), + borderColor: Colors.blue.withValues(alpha: 0.4), + borderStrokeWidth: 1, + )); + } + + // Cap at 1000 visible tiles to avoid jank + if (filtered.length >= 1000) break; + } + + if (filtered.isEmpty) return const SizedBox.shrink(); + return PolygonLayer(polygons: filtered); + }, + ); + } +} + +/// Toolbar for drawing controls. +class DrawingToolbar extends StatelessWidget { + const DrawingToolbar({super.key}); + + @override + Widget build(BuildContext context) { + return Consumer( + builder: (context, provider, _) { + if (provider.isDownloading) return const SizedBox.shrink(); + + return Positioned( + right: 16, + top: 100, + child: Column( + children: [ + _ToolButton( + icon: Icons.crop_square, + label: 'Rectangle', + isActive: provider.drawingMode == DrawingMode.rectangle, + onTap: () => provider.setDrawingMode( + provider.drawingMode == DrawingMode.rectangle + ? DrawingMode.none + : DrawingMode.rectangle, + ), + ), + const SizedBox(height: 8), + _ToolButton( + icon: Icons.pentagon_outlined, + label: 'Polygon', + isActive: provider.drawingMode == DrawingMode.polygon, + onTap: () => provider.setDrawingMode( + provider.drawingMode == DrawingMode.polygon + ? DrawingMode.none + : DrawingMode.polygon, + ), + ), + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.length >= 3) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.check, + label: 'Finish', + isActive: false, + color: Colors.green, + onTap: () => provider.finishPolygon(), + ), + ], + if (provider.drawingMode == DrawingMode.polygon && + provider.currentVertices.isNotEmpty) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.undo, + label: 'Undo', + isActive: false, + onTap: () => provider.undoLastVertex(), + ), + ], + if (provider.hasPolygons) ...[ + const SizedBox(height: 8), + _ToolButton( + icon: Icons.delete_outline, + label: 'Clear', + isActive: false, + color: Colors.red, + onTap: () => provider.clearPolygons(), + ), + ], + ], + ), + ); + }, + ); + } +} + +class _ToolButton extends StatelessWidget { + final IconData icon; + final String label; + final bool isActive; + final Color? color; + final VoidCallback onTap; + + const _ToolButton({ + required this.icon, + required this.label, + required this.isActive, + this.color, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final activeColor = color ?? theme.colorScheme.primary; + + return Tooltip( + message: label, + child: Material( + elevation: 2, + shape: const CircleBorder(), + color: isActive ? activeColor : theme.colorScheme.surface, + child: InkWell( + customBorder: const CircleBorder(), + onTap: onTap, + child: SizedBox( + width: 48, + height: 48, + child: Icon( + icon, + color: isActive + ? theme.colorScheme.onPrimary + : (color ?? theme.colorScheme.onSurface), + ), + ), + ), + ), + ); + } +} diff --git a/test/services/path_history_service_test.dart b/test/services/path_history_service_test.dart index 9b23e8c..1bb8981 100644 --- a/test/services/path_history_service_test.dart +++ b/test/services/path_history_service_test.dart @@ -71,7 +71,6 @@ void main() { ); await service.initialize(); - await service.recordLearnedPath(contact); await service.recordPathResult( contact.publicKeyHex, best, @@ -117,7 +116,7 @@ void main() { }); test( - 'current learned route is reused first even with rotation enabled', + 'contact route alone does not override history selection', () async { final service = PathHistoryService(); final contact = _buildContact( @@ -145,11 +144,46 @@ void main() { autoRouteRotationEnabled: true, ); - expect(selection.mode, PathSelectionMode.directCurrent); - expect(selection.canonicalPath, 'AABBCC'); + expect(selection.mode, PathSelectionMode.directHistorical); + expect(selection.canonicalPath, '112233'); }, ); + test('manual route overrides history selection until cleared', () async { + final service = PathHistoryService(); + final contact = _buildContactWithoutRoute(seed: 10); + + await service.initialize(); + await service.recordPathResult( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directHistorical, + pathBytes: Uint8List.fromList([0x11, 0x22]), + hopCount: 2, + hashSize: 1, + ), + success: true, + roundTripTimeMs: 100, + ); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA, 0xBB]), + hopCount: 2, + hashSize: 1, + ), + ); + + final selection = await service.getSelectionForContact( + contact, + autoRouteRotationEnabled: true, + ); + + expect(selection.mode, PathSelectionMode.directCurrent); + expect(selection.canonicalPath, 'AA,BB'); + }); + test('no history falls back to flood', () async { final service = PathHistoryService(); final contact = _buildContactWithoutRoute(seed: 0); @@ -184,7 +218,7 @@ void main() { ); test( - 'learned paths stay marked as observed after being seen on-air', + 'observed paths stay marked as observed until delivery succeeds', () async { final service = PathHistoryService(); final contact = _buildContact( @@ -199,7 +233,6 @@ void main() { 0xBB, 0xAA, ], 1); - await service.recordLearnedPath(contact); final history = service.historyFor(contact.publicKeyHex); expect(history.directPaths, hasLength(1)); @@ -259,8 +292,33 @@ void main() { expect(service.historyFor('def456').directPaths, hasLength(1)); }); + test('clearing manual route falls back to flood without history', () async { + final service = PathHistoryService(); + final contact = _buildContactWithoutRoute(seed: 11); + + await service.initialize(); + await service.setManualSelectionFor( + contact.publicKeyHex, + PathSelection( + mode: PathSelectionMode.directCurrent, + pathBytes: Uint8List.fromList([0xAA]), + hopCount: 1, + hashSize: 1, + ), + ); + + await service.clearManualRouteFor(contact.publicKeyHex); + + final selection = await service.getSelectionForContact( + contact, + autoRouteRotationEnabled: true, + ); + + expect(selection.mode, PathSelectionMode.flood); + }); + test( - 'clear history for contact suppresses immediate relearn of current route', + 'clear history for contact leaves the contact route ignored', () async { final service = PathHistoryService(); final contact = _buildContact( @@ -271,15 +329,6 @@ void main() { ); await service.initialize(); - await service.recordLearnedPath(contact); - expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1)); - - await service.clearHistoryForContact(contact); - expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); - - await service.recordLearnedPath(contact); - expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); - await service.recordPathResult( contact.publicKeyHex, PathSelection( @@ -291,12 +340,17 @@ void main() { success: true, roundTripTimeMs: 120, ); - expect(service.historyFor(contact.publicKeyHex).directPaths, hasLength(1)); - expect( - service.historyFor(contact.publicKeyHex).directPaths.single.source, - PathRecordSource.learned, + + await service.clearHistoryForContact(contact); + expect(service.historyFor(contact.publicKeyHex).directPaths, isEmpty); + + final selection = await service.getSelectionForContact( + contact, + autoRouteRotationEnabled: true, ); + + expect(selection.mode, PathSelectionMode.flood); }, );