fix: Preserve custom path priority

This commit is contained in:
Janez T
2026-04-04 15:57:41 +02:00
parent 4690ceead1
commit 512b64db9f
14 changed files with 3654 additions and 167 deletions

View File

@@ -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);
}

View File

@@ -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<TileDownloadEvent>? _downloadSubscription;
StreamSubscription<Set<TilePeer>>? _peersSubscription;
// Drawing state
DrawingMode _drawingMode = DrawingMode.none;
final List<List<LatLng>> _polygons = [];
List<LatLng> _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<TileOverlay> _tileOverlays = [];
// Cache info
int _cacheSizeBytes = 0;
// Sharing state
bool _isServerRunning = false;
Set<TilePeer> _discoveredPeers = {};
List<PeerCatalog> _peerCatalogs = [];
bool _isFetchingCatalogs = false;
bool _isSyncing = false;
String _syncStatus = '';
double _syncProgress = 0;
// Local style info
List<StyleInfo> _localStyles = [];
// Coverage overlay — which cached style's tiles to show on the map
StyleInfo? _coverageStyle;
List<TileOverlay> _coverageOverlays = [];
// Getters
DrawingMode get drawingMode => _drawingMode;
List<List<LatLng>> get polygons => List.unmodifiable(_polygons);
List<LatLng> 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<TileOverlay> get tileOverlays => _tileOverlays;
int get cacheSizeBytes => _cacheSizeBytes;
bool get hasPolygons => _polygons.isNotEmpty;
bool get isServerRunning => _isServerRunning;
Set<TilePeer> get discoveredPeers => _discoveredPeers;
List<PeerCatalog> get peerCatalogs => _peerCatalogs;
bool get isFetchingCatalogs => _isFetchingCatalogs;
bool get isSyncing => _isSyncing;
String get syncStatus => _syncStatus;
double get syncProgress => _syncProgress;
List<StyleInfo> get localStyles => _localStyles;
StyleInfo? get coverageStyle => _coverageStyle;
List<TileOverlay> 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<void> 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<void> refreshCacheSize() async {
_cacheSizeBytes = await _cache.getCacheSize();
notifyListeners();
}
Future<void> deleteStyle(StyleInfo style) async {
if (_coverageStyle?.hash == style.hash) hideCoverage();
await _cache.deleteStyle(style.hash);
await refreshCacheSize();
await refreshLocalStyles();
}
Future<void> 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<void> 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 = <TileOverlay>[];
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<void> toggleServer() async {
if (_isServerRunning) {
await _sharing.stopServer();
_isServerRunning = false;
} else {
await _sharing.startServer();
_isServerRunning = _sharing.isRunning;
}
notifyListeners();
}
Future<void> startPeerDiscovery() async {
_peersSubscription?.cancel();
_peersSubscription = _sharing.peersStream.listen((peers) {
_discoveredPeers = peers;
notifyListeners();
});
await _sharing.startDiscovery();
}
Future<void> 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<void> refreshLocalStyles() async {
_localStyles = await _cache.listStylesDetailed();
notifyListeners();
}
/// Fetch catalogs from all discovered peers to see what they have.
Future<void> 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<void> syncStyleFromPeers(StyleInfo style) async {
if (_isSyncing) return;
// Find all peers that have this style
final peersWithStyle = <TilePeer>[];
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();
}
}