mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-08-14 07:00:28 +02:00
Add offline-first tile system with per-provider caching and error retry
- Add ServicePolicy framework with OSM-specific rate limiting and TTL - Add per-provider disk tile cache (ProviderTileCacheStore) with O(1) lookup, oldest-modified eviction, and ETag/304 revalidation - Rewrite DeflockTileProvider with two paths: common (NetworkTileProvider) and offline-first (disk cache -> local tiles -> network with caching) - Add zoom-aware offline routing so tiles outside offline area zoom ranges use the efficient common path instead of the overhead-heavy offline path - Fix HTTP client lifecycle: dispose() is now a no-op for flutter_map widget recycling; shutdown() handles permanent teardown - Add TileLayerManager with exponential backoff retry (2s->60s cap), provider switch detection, and backoff reset - Guard null provider/tileType in download dialog with localized error - Fix Nominatim cache key to use normalized viewbox values - Comprehensive test coverage (1800+ lines across 6 test files) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
be446fbcbc
commit
2d92214bed
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
@@ -8,55 +9,103 @@ import 'package:http/http.dart';
|
||||
import 'package:http/retry.dart';
|
||||
|
||||
import '../app_state.dart';
|
||||
import '../models/tile_provider.dart' as models;
|
||||
import 'http_client.dart';
|
||||
import 'map_data_submodules/tiles_from_local.dart';
|
||||
import 'offline_area_service.dart';
|
||||
|
||||
/// Thrown when a tile load is cancelled (tile scrolled off screen).
|
||||
/// TileLayerManager skips retry for these — the tile is already gone.
|
||||
class TileLoadCancelledException implements Exception {
|
||||
const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
/// Thrown when a tile is not available offline (no offline area or cache hit).
|
||||
/// TileLayerManager skips retry for these — retrying won't help without network.
|
||||
class TileNotAvailableOfflineException implements Exception {
|
||||
const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
/// Custom tile provider that extends NetworkTileProvider to leverage its
|
||||
/// built-in disk cache, RetryClient, ETag revalidation, and abort support,
|
||||
/// while routing URLs through our TileType logic and supporting offline tiles.
|
||||
///
|
||||
/// Each instance is configured for a specific tile provider/type combination
|
||||
/// with frozen config — no AppState lookups at request time (except for the
|
||||
/// global offlineMode toggle).
|
||||
///
|
||||
/// Two runtime paths:
|
||||
/// 1. **Common path** (no offline areas for current provider): delegates to
|
||||
/// super.getImageWithCancelLoadingSupport() — full NetworkTileImageProvider
|
||||
/// pipeline (disk cache, ETag revalidation, RetryClient, abort support).
|
||||
/// 2. **Offline-first path** (has offline areas or offline mode): returns
|
||||
/// DeflockOfflineTileImageProvider — checks fetchLocalTile() first, falls
|
||||
/// back to HTTP via shared RetryClient on miss.
|
||||
/// DeflockOfflineTileImageProvider — checks disk cache and local tiles
|
||||
/// first, falls back to HTTP via shared RetryClient on miss.
|
||||
class DeflockTileProvider extends NetworkTileProvider {
|
||||
/// The shared HTTP client we own. We keep a reference because
|
||||
/// NetworkTileProvider._httpClient is private and _isInternallyCreatedClient
|
||||
/// will be false (we passed it in), so super.dispose() won't close it.
|
||||
final Client _sharedHttpClient;
|
||||
|
||||
DeflockTileProvider._({required Client httpClient})
|
||||
: _sharedHttpClient = httpClient,
|
||||
/// Frozen config for this provider instance.
|
||||
final String providerId;
|
||||
final models.TileType tileType;
|
||||
final String? apiKey;
|
||||
|
||||
/// Caching provider for the offline-first path. The same instance is passed
|
||||
/// to super for the common path — we keep a reference here so we can also
|
||||
/// use it in [DeflockOfflineTileImageProvider].
|
||||
final MapCachingProvider? _cachingProvider;
|
||||
|
||||
/// Called when a tile loads successfully via the network in the offline-first
|
||||
/// path. Used by [TileLayerManager] to reset exponential backoff.
|
||||
VoidCallback? onNetworkSuccess;
|
||||
|
||||
// ignore: use_super_parameters
|
||||
DeflockTileProvider._({
|
||||
required Client httpClient,
|
||||
required this.providerId,
|
||||
required this.tileType,
|
||||
this.apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
}) : _sharedHttpClient = httpClient,
|
||||
_cachingProvider = cachingProvider,
|
||||
super(
|
||||
httpClient: httpClient,
|
||||
silenceExceptions: true,
|
||||
cachingProvider: cachingProvider,
|
||||
// Let errors propagate so flutter_map marks tiles as failed
|
||||
// (loadError = true) rather than caching transparent images as
|
||||
// "successfully loaded". The TileLayerManager wires a reset stream
|
||||
// that retries failed tiles after a debounced delay.
|
||||
silenceExceptions: false,
|
||||
);
|
||||
|
||||
factory DeflockTileProvider() {
|
||||
factory DeflockTileProvider({
|
||||
required String providerId,
|
||||
required models.TileType tileType,
|
||||
String? apiKey,
|
||||
MapCachingProvider? cachingProvider,
|
||||
VoidCallback? onNetworkSuccess,
|
||||
}) {
|
||||
final client = UserAgentClient(RetryClient(Client()));
|
||||
return DeflockTileProvider._(httpClient: client);
|
||||
return DeflockTileProvider._(
|
||||
httpClient: client,
|
||||
providerId: providerId,
|
||||
tileType: tileType,
|
||||
apiKey: apiKey,
|
||||
cachingProvider: cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
|
||||
final appState = AppState.instance;
|
||||
final selectedTileType = appState.selectedTileType;
|
||||
final selectedProvider = appState.selectedTileProvider;
|
||||
|
||||
if (selectedTileType == null || selectedProvider == null) {
|
||||
// Fallback to base implementation if no provider configured
|
||||
return super.getTileUrl(coordinates, options);
|
||||
}
|
||||
|
||||
return selectedTileType.getTileUrl(
|
||||
return tileType.getTileUrl(
|
||||
coordinates.z,
|
||||
coordinates.x,
|
||||
coordinates.y,
|
||||
apiKey: selectedProvider.apiKey,
|
||||
apiKey: apiKey,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -66,7 +115,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
TileLayer options,
|
||||
Future<void> cancelLoading,
|
||||
) {
|
||||
if (!_shouldCheckOfflineCache()) {
|
||||
if (!_shouldCheckOfflineCache(coordinates.z)) {
|
||||
// Common path: no offline areas — delegate to NetworkTileProvider's
|
||||
// full pipeline (disk cache, ETag, RetryClient, abort support).
|
||||
return super.getImageWithCancelLoadingSupport(
|
||||
@@ -77,20 +126,18 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
}
|
||||
|
||||
// Offline-first path: check local tiles first, fall back to network.
|
||||
final appState = AppState.instance;
|
||||
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
|
||||
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
|
||||
|
||||
return DeflockOfflineTileImageProvider(
|
||||
coordinates: coordinates,
|
||||
options: options,
|
||||
httpClient: _sharedHttpClient,
|
||||
headers: headers,
|
||||
cancelLoading: cancelLoading,
|
||||
isOfflineOnly: appState.offlineMode,
|
||||
isOfflineOnly: AppState.instance.offlineMode,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
tileTypeId: tileType.id,
|
||||
tileUrl: getTileUrl(coordinates, options),
|
||||
cachingProvider: _cachingProvider,
|
||||
onNetworkSuccess: onNetworkSuccess,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -101,44 +148,67 @@ class DeflockTileProvider extends NetworkTileProvider {
|
||||
///
|
||||
/// This avoids the offline-first path (and its filesystem searches) when
|
||||
/// browsing online with providers that have no offline areas.
|
||||
bool _shouldCheckOfflineCache() {
|
||||
final appState = AppState.instance;
|
||||
|
||||
bool _shouldCheckOfflineCache(int zoom) {
|
||||
// Always use offline path in offline mode
|
||||
if (appState.offlineMode) {
|
||||
if (AppState.instance.offlineMode) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// For online mode, only use offline path if we have relevant offline data
|
||||
final currentProvider = appState.selectedTileProvider;
|
||||
final currentTileType = appState.selectedTileType;
|
||||
|
||||
if (currentProvider == null || currentTileType == null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// at this zoom level — tiles outside any area's zoom range go through the
|
||||
// common NetworkTileProvider path for better performance.
|
||||
final offlineService = OfflineAreaService();
|
||||
return offlineService.hasOfflineAreasForProvider(
|
||||
currentProvider.id,
|
||||
currentTileType.id,
|
||||
return offlineService.hasOfflineAreasForProviderAtZoom(
|
||||
providerId,
|
||||
tileType.id,
|
||||
zoom,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> dispose() async {
|
||||
try {
|
||||
await super.dispose();
|
||||
} finally {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
// Only call super — do NOT close _sharedHttpClient here.
|
||||
// flutter_map calls dispose() whenever the TileLayer widget is recycled
|
||||
// (e.g. provider switch causes a new FlutterMap key), but
|
||||
// TileLayerManager caches and reuses provider instances across switches.
|
||||
// Closing the HTTP client here would leave the cached instance broken —
|
||||
// all future tile requests would fail with "Client closed".
|
||||
//
|
||||
// Since we passed our own httpClient to NetworkTileProvider,
|
||||
// _isInternallyCreatedClient is false, so super.dispose() won't close it
|
||||
// either. The client is closed in [shutdown], called by
|
||||
// TileLayerManager.dispose() when the map is truly torn down.
|
||||
await super.dispose();
|
||||
}
|
||||
|
||||
/// Permanently close the HTTP client. Called by [TileLayerManager.dispose]
|
||||
/// when the map widget is being torn down — NOT by flutter_map's widget
|
||||
/// recycling.
|
||||
void shutdown() {
|
||||
_sharedHttpClient.close();
|
||||
}
|
||||
}
|
||||
|
||||
/// Image provider for the offline-first path.
|
||||
///
|
||||
/// Tries fetchLocalTile() first. On miss (and if online), falls back to an
|
||||
/// HTTP GET via the shared RetryClient. Handles cancelLoading abort and
|
||||
/// returns transparent tiles on errors (consistent with silenceExceptions).
|
||||
/// Checks disk cache and offline areas before falling back to the network.
|
||||
/// Caches successful network fetches to disk so panning back doesn't re-fetch.
|
||||
/// On cancellation, lets in-flight downloads complete and caches the result
|
||||
/// (fire-and-forget) instead of discarding downloaded bytes.
|
||||
///
|
||||
/// **Online mode flow:**
|
||||
/// 1. Disk cache (fast hash-based file read) → hit + fresh → return
|
||||
/// 2. Offline areas (file scan) → hit → return
|
||||
/// 3. Network fetch with conditional headers from stale cache entry
|
||||
/// 4. On cancel → fire-and-forget cache write for the in-flight download
|
||||
/// 5. On 304 → return stale cached bytes, update cache metadata
|
||||
/// 6. On 200 → cache to disk, decode and return
|
||||
/// 7. On error → throw (flutter_map marks tile as failed)
|
||||
///
|
||||
/// **Offline mode flow:**
|
||||
/// 1. Offline areas (primary source — guaranteed available)
|
||||
/// 2. Disk cache (tiles cached from previous online sessions)
|
||||
/// 3. Throw if both miss (flutter_map marks tile as failed)
|
||||
class DeflockOfflineTileImageProvider
|
||||
extends ImageProvider<DeflockOfflineTileImageProvider> {
|
||||
final TileCoordinates coordinates;
|
||||
@@ -150,6 +220,8 @@ class DeflockOfflineTileImageProvider
|
||||
final String providerId;
|
||||
final String tileTypeId;
|
||||
final String tileUrl;
|
||||
final MapCachingProvider? cachingProvider;
|
||||
final VoidCallback? onNetworkSuccess;
|
||||
|
||||
const DeflockOfflineTileImageProvider({
|
||||
required this.coordinates,
|
||||
@@ -161,6 +233,8 @@ class DeflockOfflineTileImageProvider
|
||||
required this.providerId,
|
||||
required this.tileTypeId,
|
||||
required this.tileUrl,
|
||||
this.cachingProvider,
|
||||
this.onNetworkSuccess,
|
||||
});
|
||||
|
||||
@override
|
||||
@@ -173,19 +247,47 @@ class DeflockOfflineTileImageProvider
|
||||
ImageStreamCompleter loadImage(
|
||||
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
|
||||
final chunkEvents = StreamController<ImageChunkEvent>();
|
||||
final codecFuture = _loadAsync(key, decode, chunkEvents);
|
||||
|
||||
codecFuture.whenComplete(() {
|
||||
chunkEvents.close();
|
||||
});
|
||||
|
||||
return MultiFrameImageStreamCompleter(
|
||||
codec: codecFuture,
|
||||
// Chain whenComplete into the codec future so there's a single future
|
||||
// for MultiFrameImageStreamCompleter to handle. Without this, the
|
||||
// whenComplete creates an orphaned future whose errors go unhandled.
|
||||
codec: _loadAsync(key, decode, chunkEvents).whenComplete(() {
|
||||
chunkEvents.close();
|
||||
}),
|
||||
chunkEvents: chunkEvents.stream,
|
||||
scale: 1.0,
|
||||
);
|
||||
}
|
||||
|
||||
/// Try to read a tile from the disk cache. Returns null on miss or error.
|
||||
Future<CachedMapTile?> _getCachedTile() async {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return null;
|
||||
try {
|
||||
return await cachingProvider!.getTile(tileUrl);
|
||||
} on CachedMapTileReadFailure {
|
||||
return null;
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a tile to the disk cache (best-effort, never throws).
|
||||
void _putCachedTile({
|
||||
required Map<String, String> responseHeaders,
|
||||
Uint8List? bytes,
|
||||
}) {
|
||||
if (cachingProvider == null || !cachingProvider!.isSupported) return;
|
||||
try {
|
||||
final metadata = CachedMapTileMetadata.fromHttpHeaders(responseHeaders);
|
||||
cachingProvider!
|
||||
.putTile(url: tileUrl, metadata: metadata, bytes: bytes)
|
||||
.catchError((_) {});
|
||||
} catch (_) {
|
||||
// Best-effort: never fail the tile load due to cache write errors.
|
||||
}
|
||||
}
|
||||
|
||||
Future<Codec> _loadAsync(
|
||||
DeflockOfflineTileImageProvider key,
|
||||
ImageDecoderCallback decode,
|
||||
@@ -194,78 +296,169 @@ class DeflockOfflineTileImageProvider
|
||||
Future<Codec> decodeBytes(Uint8List bytes) =>
|
||||
ImmutableBuffer.fromUint8List(bytes).then(decode);
|
||||
|
||||
Future<Codec> transparent() =>
|
||||
decodeBytes(TileProvider.transparentImage);
|
||||
// Track cancellation synchronously via Completer so the catch block
|
||||
// can reliably check it without microtask ordering races.
|
||||
final cancelled = Completer<void>();
|
||||
cancelLoading.then((_) {
|
||||
if (!cancelled.isCompleted) cancelled.complete();
|
||||
}).ignore();
|
||||
|
||||
try {
|
||||
// Track cancellation
|
||||
bool cancelled = false;
|
||||
cancelLoading.then((_) => cancelled = true);
|
||||
|
||||
// Try local tile first — pass captured IDs to avoid a race if the
|
||||
// user switches provider while this async load is in flight.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network if online
|
||||
if (isOfflineOnly) {
|
||||
return await _loadOffline(decodeBytes, cancelled);
|
||||
}
|
||||
|
||||
if (cancelled) return await transparent();
|
||||
if (isOfflineOnly) return await transparent();
|
||||
|
||||
// Fall back to network via shared RetryClient.
|
||||
// Race the download against cancelLoading so we stop waiting if the
|
||||
// tile is pruned mid-flight (the underlying TCP connection is cleaned
|
||||
// up naturally by the shared client).
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (statusCode: response.statusCode, bytes: bytes);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (statusCode: 0, bytes: Uint8List(0))),
|
||||
]);
|
||||
|
||||
if (cancelled || result.statusCode == 0) return await transparent();
|
||||
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
return await transparent();
|
||||
return await _loadOnline(decodeBytes, cancelled);
|
||||
} catch (e) {
|
||||
// Don't log routine offline misses
|
||||
if (!e.toString().contains('offline')) {
|
||||
debugPrint(
|
||||
'[DeflockTileProvider] Offline-first tile failed '
|
||||
'${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'(${e.runtimeType})');
|
||||
// Cancelled tiles throw — flutter_map handles the error silently.
|
||||
// Preserve TileNotAvailableOfflineException even if the tile was also
|
||||
// cancelled — it has distinct semantics (genuine cache miss) that
|
||||
// matter for diagnostics and future UI indicators.
|
||||
if (cancelled.isCompleted && e is! TileNotAvailableOfflineException) {
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
|
||||
.then(decode);
|
||||
|
||||
// Let real errors propagate so flutter_map marks loadError = true
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Online mode: disk cache → offline areas → network (with caching).
|
||||
Future<Codec> _loadOnline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check disk cache — fast hash-based file read.
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null && !cachedTile.metadata.isStale) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 2. Check offline areas — file scan per area.
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} catch (_) {
|
||||
// Local miss — fall through to network
|
||||
}
|
||||
|
||||
// 3. If cancelled before network, bail.
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
|
||||
// 4. Network fetch with conditional headers from stale cache entry.
|
||||
final request = Request('GET', Uri.parse(tileUrl));
|
||||
request.headers.addAll(headers);
|
||||
if (cachedTile != null) {
|
||||
if (cachedTile.metadata.lastModified case final lastModified?) {
|
||||
request.headers[HttpHeaders.ifModifiedSinceHeader] =
|
||||
HttpDate.format(lastModified);
|
||||
}
|
||||
if (cachedTile.metadata.etag case final etag?) {
|
||||
request.headers[HttpHeaders.ifNoneMatchHeader] = etag;
|
||||
}
|
||||
}
|
||||
|
||||
// 5. Race the download against cancelLoading.
|
||||
final networkFuture = httpClient.send(request).then((response) async {
|
||||
final bytes = await response.stream.toBytes();
|
||||
return (
|
||||
statusCode: response.statusCode,
|
||||
bytes: bytes,
|
||||
headers: response.headers,
|
||||
);
|
||||
});
|
||||
|
||||
final result = await Future.any([
|
||||
networkFuture,
|
||||
cancelLoading.then((_) => (
|
||||
statusCode: 0,
|
||||
bytes: Uint8List(0),
|
||||
headers: <String, String>{},
|
||||
)),
|
||||
]);
|
||||
|
||||
// 6. On cancel — fire-and-forget cache write for the in-flight download
|
||||
// instead of discarding the downloaded bytes.
|
||||
if (cancelled.isCompleted || result.statusCode == 0) {
|
||||
networkFuture.then((r) {
|
||||
if (r.statusCode == 200 && r.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: r.headers, bytes: r.bytes);
|
||||
}
|
||||
}).ignore();
|
||||
throw const TileLoadCancelledException();
|
||||
}
|
||||
|
||||
// 7. On 304 Not Modified → return stale cached bytes, update metadata.
|
||||
if (result.statusCode == HttpStatus.notModified && cachedTile != null) {
|
||||
_putCachedTile(responseHeaders: result.headers);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 8. On 200 OK → cache to disk, decode and return.
|
||||
if (result.statusCode == 200 && result.bytes.isNotEmpty) {
|
||||
_putCachedTile(responseHeaders: result.headers, bytes: result.bytes);
|
||||
onNetworkSuccess?.call();
|
||||
return await decodeBytes(result.bytes);
|
||||
}
|
||||
|
||||
// 9. Network error — throw so flutter_map marks the tile as failed.
|
||||
// Don't include tileUrl in the exception — it may contain API keys.
|
||||
throw HttpException(
|
||||
'Tile ${coordinates.z}/${coordinates.x}/${coordinates.y} '
|
||||
'returned status ${result.statusCode}',
|
||||
);
|
||||
}
|
||||
|
||||
/// Offline mode: offline areas → disk cache → throw.
|
||||
Future<Codec> _loadOffline(
|
||||
Future<Codec> Function(Uint8List) decodeBytes,
|
||||
Completer<void> cancelled,
|
||||
) async {
|
||||
// 1. Check offline areas (primary source — guaranteed available).
|
||||
try {
|
||||
final localBytes = await fetchLocalTile(
|
||||
z: coordinates.z,
|
||||
x: coordinates.x,
|
||||
y: coordinates.y,
|
||||
providerId: providerId,
|
||||
tileTypeId: tileTypeId,
|
||||
);
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
return await decodeBytes(Uint8List.fromList(localBytes));
|
||||
} on TileLoadCancelledException {
|
||||
rethrow;
|
||||
} catch (_) {
|
||||
// Local miss — fall through to disk cache
|
||||
}
|
||||
|
||||
// 2. Check disk cache (tiles cached from previous online sessions).
|
||||
if (cancelled.isCompleted) throw const TileLoadCancelledException();
|
||||
final cachedTile = await _getCachedTile();
|
||||
if (cachedTile != null) {
|
||||
return await decodeBytes(cachedTile.bytes);
|
||||
}
|
||||
|
||||
// 3. Both miss — throw so flutter_map marks the tile as failed.
|
||||
throw const TileNotAvailableOfflineException();
|
||||
}
|
||||
|
||||
@override
|
||||
bool operator ==(Object other) {
|
||||
if (other.runtimeType != runtimeType) return false;
|
||||
return other is DeflockOfflineTileImageProvider &&
|
||||
other.coordinates == coordinates &&
|
||||
other.providerId == providerId &&
|
||||
other.tileTypeId == tileTypeId;
|
||||
other.tileTypeId == tileTypeId &&
|
||||
other.isOfflineOnly == isOfflineOnly;
|
||||
}
|
||||
|
||||
@override
|
||||
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
|
||||
int get hashCode =>
|
||||
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:http/http.dart' as http;
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:xml/xml.dart';
|
||||
@@ -7,6 +8,7 @@ import '../../models/node_profile.dart';
|
||||
import '../../models/osm_node.dart';
|
||||
import '../../app_state.dart';
|
||||
import '../http_client.dart';
|
||||
import '../service_policy.dart';
|
||||
|
||||
/// Fetches surveillance nodes from the direct OSM API using bbox query.
|
||||
/// This is a fallback for when Overpass is not available (e.g., sandbox mode).
|
||||
@@ -58,28 +60,36 @@ Future<List<OsmNode>> _fetchFromOsmApi({
|
||||
try {
|
||||
debugPrint('[fetchOsmApiNodes] Querying OSM API for nodes in bbox...');
|
||||
debugPrint('[fetchOsmApiNodes] URL: $url');
|
||||
|
||||
final response = await _client.get(Uri.parse(url));
|
||||
|
||||
|
||||
// Enforce max 2 concurrent download threads per OSM API usage policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.osmEditingApi);
|
||||
|
||||
final http.Response response;
|
||||
try {
|
||||
response = await _client.get(Uri.parse(url));
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.osmEditingApi);
|
||||
}
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
debugPrint('[fetchOsmApiNodes] OSM API error: ${response.statusCode} - ${response.body}');
|
||||
throw Exception('OSM API error: ${response.statusCode} - ${response.body}');
|
||||
}
|
||||
|
||||
|
||||
// Parse XML response
|
||||
final document = XmlDocument.parse(response.body);
|
||||
final nodes = _parseOsmApiResponseWithConstraints(document, profiles, maxResults);
|
||||
|
||||
|
||||
if (nodes.isNotEmpty) {
|
||||
debugPrint('[fetchOsmApiNodes] Retrieved ${nodes.length} matching surveillance nodes');
|
||||
}
|
||||
|
||||
|
||||
// Don't report success here - let the top level handle it
|
||||
return nodes;
|
||||
|
||||
|
||||
} catch (e) {
|
||||
debugPrint('[fetchOsmApiNodes] Exception: $e');
|
||||
|
||||
|
||||
// Don't report status here - let the top level handle it
|
||||
rethrow; // Re-throw to let caller handle
|
||||
}
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
import 'dart:io';
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import 'package:flutter/foundation.dart' show visibleForTesting;
|
||||
|
||||
import '../offline_area_service.dart';
|
||||
import '../offline_areas/offline_area_models.dart';
|
||||
import '../offline_areas/offline_tile_utils.dart';
|
||||
import '../../app_state.dart';
|
||||
|
||||
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
|
||||
@@ -19,7 +23,7 @@ Future<List<int>> fetchLocalTile({
|
||||
final appState = AppState.instance;
|
||||
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
|
||||
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
|
||||
|
||||
|
||||
final offlineService = OfflineAreaService();
|
||||
await offlineService.ensureInitialized();
|
||||
final areas = offlineService.offlineAreas;
|
||||
@@ -28,20 +32,21 @@ Future<List<int>> fetchLocalTile({
|
||||
for (final area in areas) {
|
||||
if (area.status != OfflineAreaStatus.complete) continue;
|
||||
if (z < area.minZoom || z > area.maxZoom) continue;
|
||||
|
||||
|
||||
// Only consider areas that match the current provider/type
|
||||
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
|
||||
|
||||
// Get tile coverage for area at this zoom only
|
||||
final coveredTiles = computeTileList(area.bounds, z, z);
|
||||
final hasTile = coveredTiles.any((tile) => tile[0] == z && tile[1] == x && tile[2] == y);
|
||||
if (hasTile) {
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
if (await file.exists()) {
|
||||
final stat = await file.stat();
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
}
|
||||
// O(1) bounds check instead of enumerating all tiles at this zoom level
|
||||
if (!tileInBounds(area.bounds, z, x, y)) continue;
|
||||
|
||||
final tilePath = _tilePath(area.directory, z, x, y);
|
||||
final file = File(tilePath);
|
||||
try {
|
||||
final stat = await file.stat();
|
||||
if (stat.type == FileSystemEntityType.notFound) continue;
|
||||
candidates.add(_AreaTileMatch(area: area, file: file, modified: stat.modified));
|
||||
} on FileSystemException {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
if (candidates.isEmpty) {
|
||||
@@ -51,6 +56,34 @@ Future<List<int>> fetchLocalTile({
|
||||
return await candidates.first.file.readAsBytes();
|
||||
}
|
||||
|
||||
/// O(1) check whether tile (z, x, y) falls within the given lat/lng bounds.
|
||||
///
|
||||
/// Uses the same Mercator projection math as [latLonToTile] in
|
||||
/// offline_tile_utils.dart, but only computes the bounding tile range
|
||||
/// instead of enumerating every tile at that zoom level.
|
||||
///
|
||||
/// Note: Y axis is inverted in tile coordinates — north = lower Y.
|
||||
@visibleForTesting
|
||||
bool tileInBounds(LatLngBounds bounds, int z, int x, int y) {
|
||||
final n = pow(2.0, z);
|
||||
final west = bounds.west;
|
||||
final east = bounds.east;
|
||||
final north = bounds.north;
|
||||
final south = bounds.south;
|
||||
|
||||
final minX = ((west + 180.0) / 360.0 * n).floor();
|
||||
final maxX = ((east + 180.0) / 360.0 * n).floor();
|
||||
// North → lower Y (Mercator projection inverts latitude)
|
||||
final minY = ((1.0 - log(tan(north * pi / 180.0) +
|
||||
1.0 / cos(north * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
final maxY = ((1.0 - log(tan(south * pi / 180.0) +
|
||||
1.0 / cos(south * pi / 180.0)) /
|
||||
pi) / 2.0 * n).floor();
|
||||
|
||||
return x >= minX && x <= maxX && y >= minY && y <= maxY;
|
||||
}
|
||||
|
||||
String _tilePath(String areaDir, int z, int x, int y) =>
|
||||
'$areaDir/tiles/$z/$x/$y.png';
|
||||
|
||||
|
||||
@@ -33,14 +33,37 @@ class OfflineAreaService {
|
||||
if (!_initialized) {
|
||||
return false; // No offline areas loaded yet
|
||||
}
|
||||
|
||||
return _areas.any((area) =>
|
||||
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId
|
||||
);
|
||||
}
|
||||
|
||||
/// Like [hasOfflineAreasForProvider] but also checks that at least one area
|
||||
/// covers the given [zoom] level. Used by [DeflockTileProvider] to skip the
|
||||
/// offline-first path for tiles that will never be found locally.
|
||||
bool hasOfflineAreasForProviderAtZoom(String providerId, String tileTypeId, int zoom) {
|
||||
if (!_initialized) return false;
|
||||
return _areas.any((area) =>
|
||||
area.status == OfflineAreaStatus.complete &&
|
||||
area.tileProviderId == providerId &&
|
||||
area.tileTypeId == tileTypeId &&
|
||||
zoom >= area.minZoom &&
|
||||
zoom <= area.maxZoom
|
||||
);
|
||||
}
|
||||
|
||||
/// Reset service state and inject areas for unit tests.
|
||||
@visibleForTesting
|
||||
void setAreasForTesting(List<OfflineArea> areas) {
|
||||
_areas
|
||||
..clear()
|
||||
..addAll(areas);
|
||||
_initialized = true;
|
||||
}
|
||||
|
||||
/// Cancel all active downloads (used when enabling offline mode)
|
||||
Future<void> cancelActiveDownloads() async {
|
||||
final activeAreas = _areas.where((area) => area.status == OfflineAreaStatus.downloading).toList();
|
||||
@@ -213,7 +236,7 @@ class OfflineAreaService {
|
||||
area = OfflineArea(
|
||||
id: id,
|
||||
name: name ?? area?.name ?? '',
|
||||
bounds: bounds,
|
||||
bounds: normalizeBounds(bounds),
|
||||
minZoom: minZoom,
|
||||
maxZoom: maxZoom,
|
||||
directory: directory,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import 'package:latlong2/latlong.dart';
|
||||
import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
import '../../models/osm_node.dart';
|
||||
import 'offline_tile_utils.dart' show normalizeBounds;
|
||||
|
||||
/// Status of an offline area
|
||||
enum OfflineAreaStatus { downloading, complete, error, cancelled }
|
||||
@@ -71,10 +72,10 @@ class OfflineArea {
|
||||
};
|
||||
|
||||
static OfflineArea fromJson(Map<String, dynamic> json) {
|
||||
final bounds = LatLngBounds(
|
||||
final bounds = normalizeBounds(LatLngBounds(
|
||||
LatLng(json['bounds']['sw']['lat'], json['bounds']['sw']['lng']),
|
||||
LatLng(json['bounds']['ne']['lat'], json['bounds']['ne']['lng']),
|
||||
);
|
||||
));
|
||||
return OfflineArea(
|
||||
id: json['id'],
|
||||
name: json['name'] ?? '',
|
||||
|
||||
@@ -4,14 +4,15 @@ import 'package:flutter_map/flutter_map.dart' show LatLngBounds;
|
||||
|
||||
/// Utility for tile calculations and lat/lon conversions for OSM offline logic
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
/// Normalize bounds so south ≤ north, west ≤ east, and degenerate (near-zero)
|
||||
/// spans are expanded by epsilon. Call this before storing bounds so that
|
||||
/// `tileInBounds` and [computeTileList] see consistent corner ordering.
|
||||
LatLngBounds normalizeBounds(LatLngBounds bounds) {
|
||||
const double epsilon = 1e-7;
|
||||
double latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
double lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
double lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
// Expand degenerate/flat areas a hair
|
||||
var latMin = min(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var latMax = max(bounds.southWest.latitude, bounds.northEast.latitude);
|
||||
var lonMin = min(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
var lonMax = max(bounds.southWest.longitude, bounds.northEast.longitude);
|
||||
if ((latMax - latMin).abs() < epsilon) {
|
||||
latMin -= epsilon;
|
||||
latMax += epsilon;
|
||||
@@ -20,6 +21,16 @@ Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
lonMin -= epsilon;
|
||||
lonMax += epsilon;
|
||||
}
|
||||
return LatLngBounds(LatLng(latMin, lonMin), LatLng(latMax, lonMax));
|
||||
}
|
||||
|
||||
Set<List<int>> computeTileList(LatLngBounds bounds, int zMin, int zMax) {
|
||||
Set<List<int>> tiles = {};
|
||||
final normalized = normalizeBounds(bounds);
|
||||
final double latMin = normalized.south;
|
||||
final double latMax = normalized.north;
|
||||
final double lonMin = normalized.west;
|
||||
final double lonMax = normalized.east;
|
||||
for (int z = zMin; z <= zMax; z++) {
|
||||
final n = pow(2, z).toInt();
|
||||
final minTileRaw = latLonToTileRaw(latMin, lonMin, z);
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:path_provider/path_provider.dart';
|
||||
|
||||
import 'provider_tile_cache_store.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Factory and registry for per-provider [ProviderTileCacheStore] instances.
|
||||
///
|
||||
/// Creates cache stores under `{appCacheDir}/tile_cache/{providerId}/{tileTypeId}/`.
|
||||
/// Call [init] once at startup (e.g., from TileLayerManager.initialize) to
|
||||
/// resolve the platform cache directory. After init, [getOrCreate] is
|
||||
/// synchronous — the cache store lazily creates its directory on first write.
|
||||
class ProviderTileCacheManager {
|
||||
static final Map<String, ProviderTileCacheStore> _stores = {};
|
||||
static String? _baseCacheDir;
|
||||
|
||||
/// Resolve the platform cache directory. Call once at startup.
|
||||
static Future<void> init() async {
|
||||
if (_baseCacheDir != null) return;
|
||||
final cacheDir = await getApplicationCacheDirectory();
|
||||
_baseCacheDir = p.join(cacheDir.path, 'tile_cache');
|
||||
}
|
||||
|
||||
/// Whether the manager has been initialized.
|
||||
static bool get isInitialized => _baseCacheDir != null;
|
||||
|
||||
/// Get or create a cache store for a specific provider/tile type combination.
|
||||
///
|
||||
/// Synchronous after [init] has been called. The cache store lazily creates
|
||||
/// its directory on first write.
|
||||
static ProviderTileCacheStore getOrCreate({
|
||||
required String providerId,
|
||||
required String tileTypeId,
|
||||
required ServicePolicy policy,
|
||||
int? maxCacheBytes,
|
||||
}) {
|
||||
assert(_baseCacheDir != null,
|
||||
'ProviderTileCacheManager.init() must be called before getOrCreate()');
|
||||
|
||||
final key = '$providerId/$tileTypeId';
|
||||
if (_stores.containsKey(key)) return _stores[key]!;
|
||||
|
||||
final cacheDir = p.join(_baseCacheDir!, providerId, tileTypeId);
|
||||
|
||||
final store = ProviderTileCacheStore(
|
||||
cacheDirectory: cacheDir,
|
||||
maxCacheBytes: maxCacheBytes ?? 500 * 1024 * 1024,
|
||||
overrideFreshAge: policy.minCacheTtl,
|
||||
);
|
||||
|
||||
_stores[key] = store;
|
||||
return store;
|
||||
}
|
||||
|
||||
/// Delete a specific provider's cache directory and remove the store.
|
||||
static Future<void> deleteCache(String providerId, String tileTypeId) async {
|
||||
final key = '$providerId/$tileTypeId';
|
||||
final store = _stores.remove(key);
|
||||
if (store != null) {
|
||||
await store.clear();
|
||||
} else if (_baseCacheDir != null) {
|
||||
final cacheDir = Directory(p.join(_baseCacheDir!, providerId, tileTypeId));
|
||||
if (await cacheDir.exists()) {
|
||||
await cacheDir.delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Get estimated cache sizes for all active stores.
|
||||
///
|
||||
/// Returns a map of `providerId/tileTypeId` → size in bytes.
|
||||
static Future<Map<String, int>> getCacheSizes() async {
|
||||
final sizes = <String, int>{};
|
||||
for (final entry in _stores.entries) {
|
||||
sizes[entry.key] = await entry.value.estimatedSizeBytes;
|
||||
}
|
||||
return sizes;
|
||||
}
|
||||
|
||||
/// Remove a store from the registry (e.g., when a provider is disposed).
|
||||
static void unregister(String providerId, String tileTypeId) {
|
||||
_stores.remove('$providerId/$tileTypeId');
|
||||
}
|
||||
|
||||
/// Clear all stores and reset the registry (for testing).
|
||||
@visibleForTesting
|
||||
static Future<void> resetAll() async {
|
||||
for (final store in _stores.values) {
|
||||
await store.clear();
|
||||
}
|
||||
_stores.clear();
|
||||
_baseCacheDir = null;
|
||||
}
|
||||
|
||||
/// Set the base cache directory directly (for testing).
|
||||
@visibleForTesting
|
||||
static void setBaseCacheDir(String dir) {
|
||||
_baseCacheDir = dir;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
import 'dart:async';
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:flutter_map/flutter_map.dart';
|
||||
import 'package:path/path.dart' as p;
|
||||
import 'package:uuid/uuid.dart';
|
||||
|
||||
/// Per-provider tile cache implementing flutter_map's [MapCachingProvider].
|
||||
///
|
||||
/// Each instance manages an isolated cache directory with:
|
||||
/// - Deterministic UUID v5 key generation from tile URLs
|
||||
/// - Optional TTL override from [ServicePolicy.minCacheTtl]
|
||||
/// - Configurable max cache size with oldest-modified eviction
|
||||
///
|
||||
/// Files are stored as `{key}.tile` (image bytes) and `{key}.meta` (JSON
|
||||
/// metadata containing staleAt, lastModified, etag).
|
||||
class ProviderTileCacheStore implements MapCachingProvider {
|
||||
final String cacheDirectory;
|
||||
final int maxCacheBytes;
|
||||
final Duration? overrideFreshAge;
|
||||
|
||||
static const _uuid = Uuid();
|
||||
|
||||
/// Running estimate of cache size in bytes. Initialized lazily on first
|
||||
/// [putTile] call to avoid blocking construction.
|
||||
int? _estimatedSize;
|
||||
|
||||
/// Throttle: don't re-scan more than once per minute.
|
||||
DateTime? _lastPruneCheck;
|
||||
|
||||
/// One-shot latch for lazy directory creation (safe under concurrent calls).
|
||||
Completer<void>? _directoryReady;
|
||||
|
||||
/// Guard against concurrent eviction runs.
|
||||
bool _isEvicting = false;
|
||||
|
||||
ProviderTileCacheStore({
|
||||
required this.cacheDirectory,
|
||||
this.maxCacheBytes = 500 * 1024 * 1024, // 500 MB default
|
||||
this.overrideFreshAge,
|
||||
});
|
||||
|
||||
@override
|
||||
bool get isSupported => true;
|
||||
|
||||
@override
|
||||
Future<CachedMapTile?> getTile(String url) async {
|
||||
final key = _keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
final bytes = await tileFile.readAsBytes();
|
||||
final metaJson = json.decode(await metaFile.readAsString())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
final metadata = CachedMapTileMetadata(
|
||||
staleAt: DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['staleAt'] as int,
|
||||
isUtc: true,
|
||||
),
|
||||
lastModified: metaJson['lastModified'] != null
|
||||
? DateTime.fromMillisecondsSinceEpoch(
|
||||
metaJson['lastModified'] as int,
|
||||
isUtc: true,
|
||||
)
|
||||
: null,
|
||||
etag: metaJson['etag'] as String?,
|
||||
);
|
||||
|
||||
return (bytes: bytes, metadata: metadata);
|
||||
} on PathNotFoundException {
|
||||
return null;
|
||||
} catch (e) {
|
||||
throw CachedMapTileReadFailure(
|
||||
url: url,
|
||||
description: 'Failed to read cached tile',
|
||||
originalError: e,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Future<void> putTile({
|
||||
required String url,
|
||||
required CachedMapTileMetadata metadata,
|
||||
Uint8List? bytes,
|
||||
}) async {
|
||||
await _ensureDirectory();
|
||||
|
||||
final key = _keyFor(url);
|
||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
// Apply minimum TTL override if configured (e.g., OSM 7-day minimum).
|
||||
// Use the later of server-provided staleAt and our minimum to avoid
|
||||
// accidentally shortening a longer server-provided freshness lifetime.
|
||||
final effectiveMetadata = overrideFreshAge != null
|
||||
? (() {
|
||||
final overrideStaleAt = DateTime.timestamp().add(overrideFreshAge!);
|
||||
final staleAt = metadata.staleAt.isAfter(overrideStaleAt)
|
||||
? metadata.staleAt
|
||||
: overrideStaleAt;
|
||||
return CachedMapTileMetadata(
|
||||
staleAt: staleAt,
|
||||
lastModified: metadata.lastModified,
|
||||
etag: metadata.etag,
|
||||
);
|
||||
})()
|
||||
: metadata;
|
||||
|
||||
final metaJson = json.encode({
|
||||
'staleAt': effectiveMetadata.staleAt.millisecondsSinceEpoch,
|
||||
'lastModified':
|
||||
effectiveMetadata.lastModified?.millisecondsSinceEpoch,
|
||||
'etag': effectiveMetadata.etag,
|
||||
});
|
||||
|
||||
// Write .tile before .meta: if we crash between the two writes, the
|
||||
// read path's both-must-exist check sees a miss rather than an orphan .meta.
|
||||
if (bytes != null) {
|
||||
await tileFile.writeAsBytes(bytes);
|
||||
}
|
||||
await metaFile.writeAsString(metaJson);
|
||||
|
||||
// Reset size estimate so it resyncs from disk on next check.
|
||||
// This avoids drift from overwrites where the old size isn't subtracted.
|
||||
_estimatedSize = null;
|
||||
|
||||
// Schedule lazy size check
|
||||
_scheduleEvictionCheck();
|
||||
}
|
||||
|
||||
/// Ensure the cache directory exists (lazy creation on first write).
|
||||
///
|
||||
/// Uses a Completer latch so concurrent callers share a single create().
|
||||
/// Safe under Dart's single-threaded event loop: the null check and
|
||||
/// assignment happen in the same synchronous block with no `await`
|
||||
/// between them, so no other microtask can interleave.
|
||||
Future<void> _ensureDirectory() {
|
||||
if (_directoryReady == null) {
|
||||
final completer = Completer<void>();
|
||||
_directoryReady = completer;
|
||||
Directory(cacheDirectory).create(recursive: true).then(
|
||||
(_) => completer.complete(),
|
||||
onError: (Object error, StackTrace stackTrace) {
|
||||
// Reset latch on error so later calls can retry directory creation.
|
||||
if (identical(_directoryReady, completer)) {
|
||||
_directoryReady = null;
|
||||
}
|
||||
completer.completeError(error, stackTrace);
|
||||
},
|
||||
);
|
||||
}
|
||||
return _directoryReady!.future;
|
||||
}
|
||||
|
||||
/// Generate a cache key from URL using UUID v5 (same as flutter_map built-in).
|
||||
static String _keyFor(String url) => _uuid.v5(Namespace.url.value, url);
|
||||
|
||||
/// Estimate total cache size (lazy, first call scans directory).
|
||||
Future<int> _getEstimatedSize() async {
|
||||
if (_estimatedSize != null) return _estimatedSize!;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) {
|
||||
_estimatedSize = 0;
|
||||
return 0;
|
||||
}
|
||||
|
||||
var total = 0;
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
total += await entity.length();
|
||||
}
|
||||
}
|
||||
_estimatedSize = total;
|
||||
return total;
|
||||
}
|
||||
|
||||
/// Schedule eviction if we haven't checked recently.
|
||||
void _scheduleEvictionCheck() {
|
||||
final now = DateTime.now();
|
||||
if (_lastPruneCheck != null &&
|
||||
now.difference(_lastPruneCheck!) < const Duration(minutes: 1)) {
|
||||
return;
|
||||
}
|
||||
_lastPruneCheck = now;
|
||||
|
||||
// Fire-and-forget: eviction is best-effort background work.
|
||||
// _estimatedSize may be momentarily stale between eviction start and
|
||||
// completion, but this is acceptable — the guard only needs to be
|
||||
// approximately correct to prevent unbounded growth, and the throttle
|
||||
// ensures we re-check within a minute.
|
||||
// ignore: discarded_futures
|
||||
_evictIfNeeded();
|
||||
}
|
||||
|
||||
/// Evict oldest-modified tiles if cache exceeds size limit.
|
||||
///
|
||||
/// Sorts by file mtime (oldest first), not by last access — true LRU would
|
||||
/// require touching files on every [getTile] read, adding I/O on the hot
|
||||
/// path. In practice write-recency tracks usage well because tiles are
|
||||
/// immutable and flutter_map holds visible tiles in memory.
|
||||
///
|
||||
/// Guarded by [_isEvicting] to prevent concurrent runs from corrupting
|
||||
/// [_estimatedSize].
|
||||
Future<void> _evictIfNeeded() async {
|
||||
if (_isEvicting) return;
|
||||
_isEvicting = true;
|
||||
try {
|
||||
final currentSize = await _getEstimatedSize();
|
||||
if (currentSize <= maxCacheBytes) return;
|
||||
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (!await dir.exists()) return;
|
||||
|
||||
// Collect all files, separating .tile and .meta for eviction + orphan cleanup.
|
||||
final tileFiles = <File>[];
|
||||
final metaFiles = <String>{};
|
||||
await for (final entity in dir.list()) {
|
||||
if (entity is File) {
|
||||
if (entity.path.endsWith('.tile')) {
|
||||
tileFiles.add(entity);
|
||||
} else if (entity.path.endsWith('.meta')) {
|
||||
metaFiles.add(p.basenameWithoutExtension(entity.path));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (tileFiles.isEmpty) return;
|
||||
|
||||
// Sort by modification time, oldest first
|
||||
final stats = await Future.wait(
|
||||
tileFiles.map((f) async => (file: f, stat: await f.stat())),
|
||||
);
|
||||
stats.sort((a, b) => a.stat.modified.compareTo(b.stat.modified));
|
||||
|
||||
var freedBytes = 0;
|
||||
final targetSize = (maxCacheBytes * 0.8).toInt(); // Free down to 80%
|
||||
final evictedKeys = <String>{};
|
||||
|
||||
for (final entry in stats) {
|
||||
if (currentSize - freedBytes <= targetSize) break;
|
||||
|
||||
final key = p.basenameWithoutExtension(entry.file.path);
|
||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||
|
||||
try {
|
||||
await entry.file.delete();
|
||||
freedBytes += entry.stat.size;
|
||||
evictedKeys.add(key);
|
||||
if (await metaFile.exists()) {
|
||||
final metaStat = await metaFile.stat();
|
||||
await metaFile.delete();
|
||||
freedBytes += metaStat.size;
|
||||
}
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Failed to evict $key: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up orphan .meta files (no matching .tile file).
|
||||
// Exclude keys we just evicted — their .tile is gone so they're orphans.
|
||||
final remainingTileKeys = tileFiles
|
||||
.map((f) => p.basenameWithoutExtension(f.path))
|
||||
.toSet()
|
||||
..removeAll(evictedKeys);
|
||||
for (final metaKey in metaFiles) {
|
||||
if (!remainingTileKeys.contains(metaKey)) {
|
||||
try {
|
||||
final orphan = File(p.join(cacheDirectory, '$metaKey.meta'));
|
||||
final orphanStat = await orphan.stat();
|
||||
await orphan.delete();
|
||||
freedBytes += orphanStat.size;
|
||||
} catch (_) {
|
||||
// Best-effort cleanup
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_estimatedSize = currentSize - freedBytes;
|
||||
debugPrint(
|
||||
'[ProviderTileCacheStore] Evicted ${freedBytes ~/ 1024}KB '
|
||||
'from $cacheDirectory',
|
||||
);
|
||||
} catch (e) {
|
||||
debugPrint('[ProviderTileCacheStore] Eviction error: $e');
|
||||
} finally {
|
||||
_isEvicting = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete all cached tiles in this store's directory.
|
||||
Future<void> clear() async {
|
||||
final dir = Directory(cacheDirectory);
|
||||
if (await dir.exists()) {
|
||||
await dir.delete(recursive: true);
|
||||
}
|
||||
_estimatedSize = null;
|
||||
_directoryReady = null; // Allow lazy re-creation
|
||||
}
|
||||
|
||||
/// Get the current estimated cache size in bytes.
|
||||
Future<int> get estimatedSizeBytes => _getEstimatedSize();
|
||||
|
||||
/// Force an eviction check, bypassing the throttle.
|
||||
/// Only exposed for testing — production code uses [_scheduleEvictionCheck].
|
||||
@visibleForTesting
|
||||
Future<void> forceEviction() => _evictIfNeeded();
|
||||
}
|
||||
@@ -5,13 +5,31 @@ import 'package:latlong2/latlong.dart';
|
||||
|
||||
import '../models/search_result.dart';
|
||||
import 'http_client.dart';
|
||||
import 'service_policy.dart';
|
||||
|
||||
/// Cached search result with expiry.
|
||||
class _CachedResult {
|
||||
final List<SearchResult> results;
|
||||
final DateTime cachedAt;
|
||||
|
||||
_CachedResult(this.results) : cachedAt = DateTime.now();
|
||||
|
||||
bool get isExpired =>
|
||||
DateTime.now().difference(cachedAt) > const Duration(minutes: 5);
|
||||
}
|
||||
|
||||
class SearchService {
|
||||
static const String _baseUrl = 'https://nominatim.openstreetmap.org';
|
||||
static const int _maxResults = 5;
|
||||
static const Duration _timeout = Duration(seconds: 10);
|
||||
final _client = UserAgentClient();
|
||||
|
||||
|
||||
/// Client-side result cache, keyed by normalized query + viewbox.
|
||||
/// Required by Nominatim usage policy. Static so all SearchService
|
||||
/// instances share the cache and don't generate redundant requests.
|
||||
static final Map<String, _CachedResult> _resultCache = {};
|
||||
|
||||
|
||||
/// Search for places using Nominatim geocoding service
|
||||
Future<List<SearchResult>> search(String query, {LatLngBounds? viewbox}) async {
|
||||
if (query.trim().isEmpty) {
|
||||
@@ -27,23 +45,23 @@ class SearchService {
|
||||
// Otherwise, use Nominatim API
|
||||
return await _searchNominatim(query.trim(), viewbox: viewbox);
|
||||
}
|
||||
|
||||
|
||||
/// Try to parse various coordinate formats
|
||||
SearchResult? _tryParseCoordinates(String query) {
|
||||
// Remove common separators and normalize
|
||||
final normalized = query.replaceAll(RegExp(r'[,;]'), ' ').trim();
|
||||
final parts = normalized.split(RegExp(r'\s+'));
|
||||
|
||||
|
||||
if (parts.length != 2) return null;
|
||||
|
||||
|
||||
final lat = double.tryParse(parts[0]);
|
||||
final lon = double.tryParse(parts[1]);
|
||||
|
||||
|
||||
if (lat == null || lon == null) return null;
|
||||
|
||||
|
||||
// Basic validation for Earth coordinates
|
||||
if (lat < -90 || lat > 90 || lon < -180 || lon > 180) return null;
|
||||
|
||||
|
||||
return SearchResult(
|
||||
displayName: 'Coordinates: ${lat.toStringAsFixed(6)}, ${lon.toStringAsFixed(6)}',
|
||||
coordinates: LatLng(lat, lon),
|
||||
@@ -51,17 +69,17 @@ class SearchService {
|
||||
type: 'point',
|
||||
);
|
||||
}
|
||||
|
||||
/// Search using Nominatim API
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
/// Search using Nominatim API with rate limiting and result caching.
|
||||
///
|
||||
/// Nominatim usage policy requires:
|
||||
/// - Max 1 request per second
|
||||
/// - Client-side result caching
|
||||
/// - No auto-complete / typeahead
|
||||
Future<List<SearchResult>> _searchNominatim(String query, {LatLngBounds? viewbox}) async {
|
||||
// Normalize the viewbox first so both the cache key and the request
|
||||
// params use the same effective values (rounded + min-span expanded).
|
||||
String? viewboxParam;
|
||||
if (viewbox != null) {
|
||||
double round1(double v) => (v * 10).round() / 10;
|
||||
var west = round1(viewbox.west);
|
||||
@@ -80,31 +98,83 @@ class SearchService {
|
||||
north = mid + 0.25;
|
||||
}
|
||||
|
||||
params['viewbox'] = '$west,$north,$east,$south';
|
||||
viewboxParam = '$west,$north,$east,$south';
|
||||
}
|
||||
|
||||
final cacheKey = _buildCacheKey(query, viewboxParam);
|
||||
|
||||
// Check cache first (Nominatim policy requires client-side caching)
|
||||
final cached = _resultCache[cacheKey];
|
||||
if (cached != null && !cached.isExpired) {
|
||||
debugPrint('[SearchService] Cache hit for "$query"');
|
||||
return cached.results;
|
||||
}
|
||||
|
||||
final params = {
|
||||
'q': query,
|
||||
'format': 'json',
|
||||
'limit': _maxResults.toString(),
|
||||
'addressdetails': '1',
|
||||
'extratags': '1',
|
||||
};
|
||||
|
||||
if (viewboxParam != null) {
|
||||
params['viewbox'] = viewboxParam;
|
||||
}
|
||||
|
||||
final uri = Uri.parse('$_baseUrl/search').replace(queryParameters: params);
|
||||
|
||||
|
||||
debugPrint('[SearchService] Searching Nominatim: $uri');
|
||||
|
||||
|
||||
// Rate limit: max 1 request/sec per Nominatim policy
|
||||
await ServiceRateLimiter.acquire(ServiceType.nominatim);
|
||||
try {
|
||||
final response = await _client.get(uri).timeout(_timeout);
|
||||
|
||||
|
||||
if (response.statusCode != 200) {
|
||||
throw Exception('HTTP ${response.statusCode}: ${response.reasonPhrase}');
|
||||
}
|
||||
|
||||
|
||||
final List<dynamic> jsonResults = json.decode(response.body);
|
||||
final results = jsonResults
|
||||
.map((json) => SearchResult.fromNominatim(json as Map<String, dynamic>))
|
||||
.toList();
|
||||
|
||||
|
||||
// Cache the results
|
||||
_resultCache[cacheKey] = _CachedResult(results);
|
||||
_pruneCache();
|
||||
|
||||
debugPrint('[SearchService] Found ${results.length} results');
|
||||
return results;
|
||||
|
||||
} catch (e) {
|
||||
} catch (e, stackTrace) {
|
||||
debugPrint('[SearchService] Search failed: $e');
|
||||
throw Exception('Search failed: $e');
|
||||
Error.throwWithStackTrace(e, stackTrace);
|
||||
} finally {
|
||||
ServiceRateLimiter.release(ServiceType.nominatim);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a cache key from the query and the already-normalized viewbox string.
|
||||
///
|
||||
/// The viewbox should be the same `west,north,east,south` string sent to
|
||||
/// Nominatim (after rounding and min-span expansion) so that requests with
|
||||
/// different raw bounds but the same effective viewbox share a cache entry.
|
||||
String _buildCacheKey(String query, String? viewboxParam) {
|
||||
final normalizedQuery = query.trim().toLowerCase();
|
||||
if (viewboxParam == null) return normalizedQuery;
|
||||
return '$normalizedQuery|$viewboxParam';
|
||||
}
|
||||
|
||||
/// Remove expired entries and limit cache size.
|
||||
void _pruneCache() {
|
||||
_resultCache.removeWhere((_, cached) => cached.isExpired);
|
||||
// Limit cache to 50 entries to prevent unbounded growth
|
||||
if (_resultCache.length > 50) {
|
||||
final sortedKeys = _resultCache.keys.toList()
|
||||
..sort((a, b) => _resultCache[a]!.cachedAt.compareTo(_resultCache[b]!.cachedAt));
|
||||
for (final key in sortedKeys.take(_resultCache.length - 50)) {
|
||||
_resultCache.remove(key);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,400 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
/// Identifies the type of external service being accessed.
|
||||
/// Used by [ServicePolicyResolver] to determine the correct compliance policy.
|
||||
enum ServiceType {
|
||||
// OSMF official services
|
||||
osmEditingApi, // api.openstreetmap.org — editing & data queries
|
||||
osmTileServer, // tile.openstreetmap.org — raster tiles
|
||||
nominatim, // nominatim.openstreetmap.org — geocoding
|
||||
overpass, // overpass-api.de — read-only data queries
|
||||
tagInfo, // taginfo.openstreetmap.org — tag metadata
|
||||
|
||||
// Third-party tile services
|
||||
bingTiles, // *.tiles.virtualearth.net
|
||||
mapboxTiles, // api.mapbox.com
|
||||
|
||||
// Everything else
|
||||
custom, // user's own infrastructure / unknown
|
||||
}
|
||||
|
||||
/// Defines the compliance rules for a specific service.
|
||||
///
|
||||
/// Each policy captures the rate limits, caching requirements, offline
|
||||
/// permissions, and attribution obligations mandated by the service operator.
|
||||
/// When the app talks to official OSMF infrastructure the strict policies
|
||||
/// apply; when the user configures self-hosted endpoints, [ServicePolicy.custom]
|
||||
/// provides permissive defaults.
|
||||
class ServicePolicy {
|
||||
/// Max concurrent HTTP connections to this service.
|
||||
/// A value of 0 means "managed elsewhere" (e.g., by flutter_map or PR #114).
|
||||
final int maxConcurrentRequests;
|
||||
|
||||
/// Minimum interval between consecutive requests. Null means no rate limit.
|
||||
final Duration? minRequestInterval;
|
||||
|
||||
/// Whether this endpoint permits offline/bulk downloading of tiles.
|
||||
final bool allowsOfflineDownload;
|
||||
|
||||
/// Whether the client must cache responses (e.g., Nominatim policy).
|
||||
final bool requiresClientCaching;
|
||||
|
||||
/// Minimum cache TTL to enforce regardless of server headers.
|
||||
/// Null means "use server-provided max-age as-is".
|
||||
final Duration? minCacheTtl;
|
||||
|
||||
/// License/attribution URL to display in the attribution dialog.
|
||||
/// Null means no special attribution link is needed.
|
||||
final String? attributionUrl;
|
||||
|
||||
const ServicePolicy({
|
||||
this.maxConcurrentRequests = 8,
|
||||
this.minRequestInterval,
|
||||
this.allowsOfflineDownload = true,
|
||||
this.requiresClientCaching = false,
|
||||
this.minCacheTtl,
|
||||
this.attributionUrl,
|
||||
});
|
||||
|
||||
/// OSM editing API (api.openstreetmap.org)
|
||||
/// Policy: max 2 concurrent download threads.
|
||||
/// https://operations.osmfoundation.org/policies/api/
|
||||
const ServicePolicy.osmEditingApi()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a for API
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// OSM tile server (tile.openstreetmap.org)
|
||||
/// Policy: no offline/bulk downloading, min 7-day cache, must honor cache headers.
|
||||
/// Concurrency managed by flutter_map's NetworkTileProvider.
|
||||
/// https://operations.osmfoundation.org/policies/tiles/
|
||||
const ServicePolicy.osmTileServer()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = false,
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = const Duration(days: 7),
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Nominatim geocoding (nominatim.openstreetmap.org)
|
||||
/// Policy: max 1 req/sec, single machine only, results must be cached.
|
||||
/// https://operations.osmfoundation.org/policies/nominatim/
|
||||
const ServicePolicy.nominatim()
|
||||
: maxConcurrentRequests = 1,
|
||||
minRequestInterval = const Duration(seconds: 1),
|
||||
allowsOfflineDownload = true, // n/a for geocoding
|
||||
requiresClientCaching = true,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = 'https://www.openstreetmap.org/copyright';
|
||||
|
||||
/// Overpass API (overpass-api.de)
|
||||
/// Concurrency and rate limiting managed by PR #114's _AsyncSemaphore.
|
||||
const ServicePolicy.overpass()
|
||||
: maxConcurrentRequests = 0, // managed by NodeDataManager
|
||||
minRequestInterval = null, // managed by NodeDataManager
|
||||
allowsOfflineDownload = true, // n/a for data queries
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// TagInfo API (taginfo.openstreetmap.org)
|
||||
const ServicePolicy.tagInfo()
|
||||
: maxConcurrentRequests = 2,
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // n/a
|
||||
requiresClientCaching = true, // already cached in NSIService
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Bing Maps tiles (*.tiles.virtualearth.net)
|
||||
const ServicePolicy.bingTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // check Bing ToS separately
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Mapbox tiles (api.mapbox.com)
|
||||
const ServicePolicy.mapboxTiles()
|
||||
: maxConcurrentRequests = 0, // managed by flutter_map
|
||||
minRequestInterval = null,
|
||||
allowsOfflineDownload = true, // permitted with valid token
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = null;
|
||||
|
||||
/// Custom/self-hosted service — permissive defaults.
|
||||
const ServicePolicy.custom({
|
||||
int maxConcurrent = 8,
|
||||
bool allowsOffline = true,
|
||||
Duration? minInterval,
|
||||
String? attribution,
|
||||
}) : maxConcurrentRequests = maxConcurrent,
|
||||
minRequestInterval = minInterval,
|
||||
allowsOfflineDownload = allowsOffline,
|
||||
requiresClientCaching = false,
|
||||
minCacheTtl = null,
|
||||
attributionUrl = attribution;
|
||||
|
||||
@override
|
||||
String toString() => 'ServicePolicy('
|
||||
'maxConcurrent: $maxConcurrentRequests, '
|
||||
'minInterval: $minRequestInterval, '
|
||||
'offlineDownload: $allowsOfflineDownload, '
|
||||
'clientCaching: $requiresClientCaching, '
|
||||
'minCacheTtl: $minCacheTtl, '
|
||||
'attributionUrl: $attributionUrl)';
|
||||
}
|
||||
|
||||
/// Resolves URLs and tile providers to their applicable [ServicePolicy].
|
||||
///
|
||||
/// Built-in patterns cover all OSMF official services and common third-party
|
||||
/// tile providers. Custom overrides can be registered for self-hosted endpoints
|
||||
/// via [registerCustomPolicy].
|
||||
class ServicePolicyResolver {
|
||||
/// Host → ServiceType mapping for known services.
|
||||
static final Map<String, ServiceType> _hostPatterns = {
|
||||
'api.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'api06.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'master.apis.dev.openstreetmap.org': ServiceType.osmEditingApi,
|
||||
'tile.openstreetmap.org': ServiceType.osmTileServer,
|
||||
'nominatim.openstreetmap.org': ServiceType.nominatim,
|
||||
'overpass-api.de': ServiceType.overpass,
|
||||
'taginfo.openstreetmap.org': ServiceType.tagInfo,
|
||||
'tiles.virtualearth.net': ServiceType.bingTiles,
|
||||
'api.mapbox.com': ServiceType.mapboxTiles,
|
||||
};
|
||||
|
||||
/// ServiceType → policy mapping.
|
||||
static final Map<ServiceType, ServicePolicy> _policies = {
|
||||
ServiceType.osmEditingApi: const ServicePolicy.osmEditingApi(),
|
||||
ServiceType.osmTileServer: const ServicePolicy.osmTileServer(),
|
||||
ServiceType.nominatim: const ServicePolicy.nominatim(),
|
||||
ServiceType.overpass: const ServicePolicy.overpass(),
|
||||
ServiceType.tagInfo: const ServicePolicy.tagInfo(),
|
||||
ServiceType.bingTiles: const ServicePolicy.bingTiles(),
|
||||
ServiceType.mapboxTiles: const ServicePolicy.mapboxTiles(),
|
||||
ServiceType.custom: const ServicePolicy(),
|
||||
};
|
||||
|
||||
/// Custom host overrides registered at runtime (for self-hosted services).
|
||||
static final Map<String, ServicePolicy> _customOverrides = {};
|
||||
|
||||
/// Resolve a URL to its applicable [ServicePolicy].
|
||||
///
|
||||
/// Checks custom overrides first, then built-in host patterns. Falls back
|
||||
/// to [ServicePolicy.custom] for unrecognized hosts.
|
||||
static ServicePolicy resolve(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return const ServicePolicy();
|
||||
|
||||
// Check custom overrides first (exact or subdomain matching)
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Check built-in patterns (support subdomain matching)
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return _policies[entry.value] ?? const ServicePolicy();
|
||||
}
|
||||
}
|
||||
|
||||
return const ServicePolicy();
|
||||
}
|
||||
|
||||
/// Resolve a URL to its [ServiceType].
|
||||
///
|
||||
/// Returns [ServiceType.custom] for unrecognized hosts.
|
||||
static ServiceType resolveType(String url) {
|
||||
final host = _extractHost(url);
|
||||
if (host == null) return ServiceType.custom;
|
||||
|
||||
// Check custom overrides first — a registered custom policy means
|
||||
// the host is treated as ServiceType.custom with custom rules.
|
||||
for (final entry in _customOverrides.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return ServiceType.custom;
|
||||
}
|
||||
}
|
||||
|
||||
for (final entry in _hostPatterns.entries) {
|
||||
if (host == entry.key || host.endsWith('.${entry.key}')) {
|
||||
return entry.value;
|
||||
}
|
||||
}
|
||||
|
||||
return ServiceType.custom;
|
||||
}
|
||||
|
||||
/// Look up the [ServicePolicy] for a known [ServiceType].
|
||||
static ServicePolicy resolveByType(ServiceType type) =>
|
||||
_policies[type] ?? const ServicePolicy();
|
||||
|
||||
/// Register a custom policy override for a host pattern.
|
||||
///
|
||||
/// Use this to configure self-hosted services:
|
||||
/// ```dart
|
||||
/// ServicePolicyResolver.registerCustomPolicy(
|
||||
/// 'tiles.myserver.com',
|
||||
/// ServicePolicy.custom(allowsOffline: true, maxConcurrent: 20),
|
||||
/// );
|
||||
/// ```
|
||||
static void registerCustomPolicy(String hostPattern, ServicePolicy policy) {
|
||||
_customOverrides[hostPattern] = policy;
|
||||
}
|
||||
|
||||
/// Remove a custom policy override.
|
||||
static void removeCustomPolicy(String hostPattern) {
|
||||
_customOverrides.remove(hostPattern);
|
||||
}
|
||||
|
||||
/// Clear all custom policy overrides (useful for testing).
|
||||
static void clearCustomPolicies() {
|
||||
_customOverrides.clear();
|
||||
}
|
||||
|
||||
/// Extract the host from a URL or URL template.
|
||||
static String? _extractHost(String url) {
|
||||
// Handle URL templates like 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'
|
||||
// and subdomain templates like 'https://ecn.t{0_3}.tiles.virtualearth.net/...'
|
||||
try {
|
||||
// Strip template variables from subdomain part for parsing
|
||||
final cleaned = url
|
||||
.replaceAll(RegExp(r'\{0_3\}'), '0')
|
||||
.replaceAll(RegExp(r'\{1_4\}'), '1')
|
||||
.replaceAll(RegExp(r'\{quadkey\}'), 'quadkey')
|
||||
.replaceAll(RegExp(r'\{z\}'), '0')
|
||||
.replaceAll(RegExp(r'\{x\}'), '0')
|
||||
.replaceAll(RegExp(r'\{y\}'), '0')
|
||||
.replaceAll(RegExp(r'\{api_key\}'), 'key');
|
||||
return Uri.parse(cleaned).host.toLowerCase();
|
||||
} catch (_) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Reusable per-service rate limiter and concurrency controller.
|
||||
///
|
||||
/// Enforces the rate limits and concurrency constraints defined in each
|
||||
/// service's [ServicePolicy]. Call [acquire] before making a request and
|
||||
/// [release] after the request completes.
|
||||
///
|
||||
/// Only manages services whose policies have [ServicePolicy.maxConcurrentRequests] > 0
|
||||
/// and/or [ServicePolicy.minRequestInterval] set. Services managed elsewhere
|
||||
/// (flutter_map, PR #114) are passed through without blocking.
|
||||
class ServiceRateLimiter {
|
||||
/// Injectable clock for testing. Defaults to [DateTime.now].
|
||||
///
|
||||
/// Override with a deterministic clock (e.g. from `FakeAsync`) so tests
|
||||
/// don't rely on wall-clock time and stay fast and stable under CI load.
|
||||
@visibleForTesting
|
||||
static DateTime Function() clock = DateTime.now;
|
||||
|
||||
/// Per-service timestamps of the last acquired request slot / request start
|
||||
/// (used for rate limiting in [acquire], not updated on completion).
|
||||
static final Map<ServiceType, DateTime> _lastRequestTime = {};
|
||||
|
||||
/// Per-service concurrency semaphores.
|
||||
static final Map<ServiceType, _Semaphore> _semaphores = {};
|
||||
|
||||
/// Acquire a slot: wait for rate limit compliance, then take a connection slot.
|
||||
///
|
||||
/// Blocks if:
|
||||
/// 1. The minimum interval between requests hasn't elapsed yet, or
|
||||
/// 2. All concurrent connection slots are in use.
|
||||
static Future<void> acquire(ServiceType service) async {
|
||||
final policy = ServicePolicyResolver.resolveByType(service);
|
||||
|
||||
// Concurrency: acquire semaphore slot first, so only one caller at a
|
||||
// time proceeds to the rate-limit check. This prevents concurrent
|
||||
// callers from bypassing the min interval when _lastRequestTime is
|
||||
// still null or stale.
|
||||
_Semaphore? semaphore;
|
||||
if (policy.maxConcurrentRequests > 0) {
|
||||
semaphore = _semaphores.putIfAbsent(
|
||||
service,
|
||||
() => _Semaphore(policy.maxConcurrentRequests),
|
||||
);
|
||||
await semaphore.acquire();
|
||||
}
|
||||
|
||||
try {
|
||||
// Rate limit: wait if we sent a request too recently
|
||||
if (policy.minRequestInterval != null) {
|
||||
final lastTime = _lastRequestTime[service];
|
||||
if (lastTime != null) {
|
||||
final elapsed = clock().difference(lastTime);
|
||||
final remaining = policy.minRequestInterval! - elapsed;
|
||||
if (remaining > Duration.zero) {
|
||||
debugPrint('[ServiceRateLimiter] Throttling $service for ${remaining.inMilliseconds}ms');
|
||||
await Future.delayed(remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Record request time
|
||||
_lastRequestTime[service] = clock();
|
||||
} catch (_) {
|
||||
// Release the semaphore slot if the rate-limit delay fails,
|
||||
// to avoid permanently leaking a slot.
|
||||
semaphore?.release();
|
||||
rethrow;
|
||||
}
|
||||
}
|
||||
|
||||
/// Release a connection slot after request completes.
|
||||
static void release(ServiceType service) {
|
||||
_semaphores[service]?.release();
|
||||
}
|
||||
|
||||
/// Reset all rate limiter state (for testing).
|
||||
@visibleForTesting
|
||||
static void reset() {
|
||||
_lastRequestTime.clear();
|
||||
_semaphores.clear();
|
||||
clock = DateTime.now;
|
||||
}
|
||||
}
|
||||
|
||||
/// Simple async counting semaphore for concurrency limiting.
|
||||
class _Semaphore {
|
||||
final int _maxCount;
|
||||
int _currentCount = 0;
|
||||
final List<Completer<void>> _waiters = [];
|
||||
|
||||
_Semaphore(this._maxCount);
|
||||
|
||||
Future<void> acquire() async {
|
||||
if (_currentCount < _maxCount) {
|
||||
_currentCount++;
|
||||
return;
|
||||
}
|
||||
final completer = Completer<void>();
|
||||
_waiters.add(completer);
|
||||
await completer.future;
|
||||
}
|
||||
|
||||
void release() {
|
||||
if (_waiters.isNotEmpty) {
|
||||
final next = _waiters.removeAt(0);
|
||||
next.complete();
|
||||
} else if (_currentCount > 0) {
|
||||
_currentCount--;
|
||||
} else {
|
||||
throw StateError(
|
||||
'Semaphore.release() called more times than acquire(); '
|
||||
'currentCount is already zero.',
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user