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

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

View File

@@ -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<List<List<double>>> polygons; // [polygon][vertex][lat, lng]
final int minZoom;
final int maxZoom;
const DownloadRegion({
required this.polygons,
required this.minZoom,
required this.maxZoom,
});
Map<String, dynamic> toJson() => {
'polygons': polygons,
'minZoom': minZoom,
'maxZoom': maxZoom,
};
factory DownloadRegion.fromJson(Map<String, dynamic> json) {
final rawPolygons = json['polygons'] as List<dynamic>? ?? [];
final polygons = rawPolygons.map<List<List<double>>>((poly) {
return (poly as List<dynamic>).map<List<double>>((vertex) {
final v = vertex as List<dynamic>;
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<String, dynamic> toJson() => {
'hash': hash,
'displayName': displayName,
'urlTemplate': urlTemplate,
'tileCount': tileCount,
'sizeBytes': sizeBytes,
if (region != null) 'region': region!.toJson(),
};
factory StyleInfo.fromJson(Map<String, dynamic> 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<String, dynamic>)
: 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<String, int> toJson() => {'z': z, 'x': x, 'y': y};
factory CachedTileCoord.fromJson(Map<String, dynamic> 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<String> 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<String, Set<String>> _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<Set<String>> loadManifest(String styleHash) async {
if (_manifests.containsKey(styleHash)) return _manifests[styleHash]!;
final base = await baseDir;
final file = File(_manifestPath(base, styleHash));
final Set<String> 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<void> _writeManifest(
String base, String styleHash, Set<String> 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<void> _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<bool> 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<Uint8List?> 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<Uint8List?> 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<void> 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<void> 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<int> 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<List<String>> listStyles() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final styles = <String>[];
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<void> 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<String, dynamic> 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<StyleInfo?> 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<String, dynamic>)
: null,
);
} catch (_) {
return null;
}
}
/// List all styles with metadata, tile counts, and sizes.
Future<List<StyleInfo>> listStylesDetailed() async {
final base = await baseDir;
final dir = Directory(base);
if (!await dir.exists()) return [];
final results = <StyleInfo>[];
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<String, dynamic>);
}
} 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<List<CachedTileCoord>> listTilesForStyle(String styleHash) async {
final base = await baseDir;
final styleDir = Directory('$base/$styleHash');
if (!await styleDir.exists()) return [];
final tiles = <CachedTileCoord>[];
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<void> 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<void> 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<Uint8List?> getTileAsPngStatic(Uint8List avifBytes) {
return _avifToPng(avifBytes);
}
/// Encode PNG → AVIF for tile storage.
/// Uses moderate quality for good compression with acceptable quality.
static Future<Uint8List?> _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<Uint8List?> _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;
}
}
}

View File

@@ -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<int> pathBytes;
final int hopCount;
final int hashSize;
const _ManualPathSelectionRecord({
required this.pathBytes,
required this.hopCount,
required this.hashSize,
});
factory _ManualPathSelectionRecord.fromJson(Map<String, dynamic> json) {
final pathBytes = (json['pathBytes'] as List<dynamic>? ?? const <dynamic>[])
.whereType<int>()
.toList();
return _ManualPathSelectionRecord(
pathBytes: pathBytes,
hopCount: json['hopCount'] as int? ?? 0,
hashSize: json['hashSize'] as int? ?? 1,
);
}
Map<String, dynamic> 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<String, ContactPathHistory> _cache = {};
final Map<String, String> _suppressedCurrentRoutes = {};
final Map<String, _ManualPathSelectionRecord> _manualSelections = {};
bool _isLoaded = false;
Future<void> 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<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_cache[entry.key] = ContactPathHistory.fromJson(entry.key, value);
}
final decoded = jsonDecode(raw);
if (decoded is Map<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is Map<String, dynamic>) {
_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<String, dynamic>) {
for (final entry in decoded.entries) {
final value = entry.value;
if (value is String && value.isNotEmpty) {
_suppressedCurrentRoutes[entry.key] = value;
if (value is Map<String, dynamic>) {
_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<void> 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<void> recordReceivedBytePath(
String contactPublicKeyHex,
List<int> 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<void> 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<void> setManualSelectionFor(
String contactPublicKeyHex,
PathSelection selection,
) async {
await initialize();
_manualSelections[contactPublicKeyHex] = _ManualPathSelectionRecord(
pathBytes: selection.pathBytes.toList(),
hopCount: selection.hopCount,
hashSize: selection.hashSize,
);
await _persistState();
}
Future<PathSelection?> getManualSelectionForContact(Contact contact) async {
await initialize();
return _manualSelections[contact.publicKeyHex]?.toSelection();
}
Future<void> clearManualRouteFor(String contactPublicKeyHex) async {
await initialize();
_manualSelections.remove(contactPublicKeyHex);
await _persistState();
}
Future<void> clearHistoryFor(String contactPublicKeyHex) async {
await initialize();
_cache.remove(contactPublicKeyHex);
_suppressedCurrentRoutes.remove(contactPublicKeyHex);
await _persistState();
}
Future<void> 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<void> _persistState() async {
final prefs = await SharedPreferences.getInstance();
final payload = <String, dynamic>{};
for (final entry in _cache.entries) {
payload[entry.key] = entry.value.toJson();
}
final suppressedPayload = <String, dynamic>{};
for (final entry in _suppressedCurrentRoutes.entries) {
suppressedPayload[entry.key] = entry.value;
final manualPayload = <String, dynamic>{};
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<PathRecord> _upsertDirectPath(

View File

@@ -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<TileDownloadEvent> downloadTiles({
required List<List<LatLng>> polygons,
required int minZoom,
required int maxZoom,
required String urlTemplate,
String? displayName,
int maxConcurrency = 6,
int rateLimit = 30,
}) {
final controller = StreamController<TileDownloadEvent>();
_runDownload(
controller: controller,
polygons: polygons,
minZoom: minZoom,
maxZoom: maxZoom,
urlTemplate: urlTemplate,
displayName: displayName,
maxConcurrency: maxConcurrency,
rateLimit: rateLimit,
);
return controller.stream;
}
Future<void> _runDownload({
required StreamController<TileDownloadEvent> controller,
required List<List<LatLng>> 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 = <TileCoord>[];
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 = <Future<void>>[];
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<TileDownloadEvent> _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 = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_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 = <DateTime>[];
_RateLimiter(this.maxPerSecond);
Future<void> 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());
}
}

View File

@@ -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<LatLng> 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<LatLng> 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<TileCoord> getTilesForPolygons(
List<List<LatLng>> polygons,
int minZoom,
int maxZoom,
) {
final tileSet = <TileCoord>{};
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<List<LatLng>> 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;
}
}

View File

@@ -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<StyleInfo> 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<Set<TilePeer>>.broadcast();
final Set<TilePeer> _discoveredPeers = {};
bool _syncCancelled = false;
bool get isRunning => _server != null;
Stream<Set<TilePeer>> get peersStream => _peersController.stream;
Set<TilePeer> get discoveredPeers => Set.unmodifiable(_discoveredPeers);
// ── Server ──────────────────────────────────────────────────────────────
Future<void> 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<void> stopServer() async {
await _stopAdvertising();
await _server?.close();
_server = null;
debugPrint('[TileSharing] Server stopped');
}
// ── Discovery ───────────────────────────────────────────────────────────
Future<void> 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<void> 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<PeerCatalog?> 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<dynamic> data = jsonDecode(response.body);
final styles = data
.map((e) => StyleInfo.fromJson(e as Map<String, dynamic>))
.toList();
return PeerCatalog(peer: peer, styles: styles);
} catch (e) {
debugPrint('[TileSharing] fetchPeerCatalog(${peer.ipAddress}): $e');
return null;
}
}
/// Fetch catalogs from all discovered peers.
Future<List<PeerCatalog>> fetchAllPeerCatalogs() async {
final futures =
_discoveredPeers.map((peer) => fetchPeerCatalog(peer)).toList();
final results = await Future.wait(futures);
return results.whereType<PeerCatalog>().toList();
}
/// Fetch the tile list for a style from a peer.
Future<List<CachedTileCoord>?> 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<dynamic> data = jsonDecode(response.body);
return data
.map((e) => CachedTileCoord.fromJson(e as Map<String, dynamic>))
.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<Uint8List?> 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<Uint8List?> 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<PeerSyncEvent> syncStyleFromPeers({
required List<TilePeer> peers,
required String styleHash,
required StyleInfo styleMeta,
int maxConcurrency = 8,
}) {
final controller = StreamController<PeerSyncEvent>();
_runSync(
controller: controller,
peers: peers,
styleHash: styleHash,
styleMeta: styleMeta,
maxConcurrency: maxConcurrency,
);
return controller.stream;
}
Future<void> _runSync({
required StreamController<PeerSyncEvent> controller,
required List<TilePeer> 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 = <String, CachedTileCoord>{};
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 = <Future<void>>[];
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<void> _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<void> _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 = <Completer<void>>[];
_Semaphore(this.maxCount);
Future<void> acquire() async {
if (_currentCount < maxCount) {
_currentCount++;
return;
}
final completer = Completer<void>();
_waitQueue.add(completer);
await completer.future;
}
void release() {
if (_waitQueue.isNotEmpty) {
_waitQueue.removeAt(0).complete();
} else {
_currentCount--;
}
}
}