diff --git a/lib/services/deflock_tile_provider.dart b/lib/services/deflock_tile_provider.dart index 9ab86cb..7b1d6ce 100644 --- a/lib/services/deflock_tile_provider.dart +++ b/lib/services/deflock_tile_provider.dart @@ -52,6 +52,10 @@ class DeflockTileProvider extends NetworkTileProvider { final models.TileType tileType; final String? apiKey; + /// Opaque fingerprint of the config this provider was created with. + /// Used by [TileLayerManager] to detect config drift after edits. + final String configFingerprint; + /// 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]. @@ -69,6 +73,7 @@ class DeflockTileProvider extends NetworkTileProvider { this.apiKey, MapCachingProvider? cachingProvider, this.onNetworkSuccess, + this.configFingerprint = '', }) : _sharedHttpClient = httpClient, _cachingProvider = cachingProvider, super( @@ -87,6 +92,7 @@ class DeflockTileProvider extends NetworkTileProvider { String? apiKey, MapCachingProvider? cachingProvider, VoidCallback? onNetworkSuccess, + String configFingerprint = '', }) { final client = UserAgentClient(RetryClient(Client())); return DeflockTileProvider._( @@ -96,6 +102,7 @@ class DeflockTileProvider extends NetworkTileProvider { apiKey: apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onNetworkSuccess, + configFingerprint: configFingerprint, ); } diff --git a/lib/services/provider_tile_cache_manager.dart b/lib/services/provider_tile_cache_manager.dart index 54e99d3..cdbce71 100644 --- a/lib/services/provider_tile_cache_manager.dart +++ b/lib/services/provider_tile_cache_manager.dart @@ -37,8 +37,11 @@ class ProviderTileCacheManager { required ServicePolicy policy, int? maxCacheBytes, }) { - assert(_baseCacheDir != null, - 'ProviderTileCacheManager.init() must be called before getOrCreate()'); + if (_baseCacheDir == null) { + throw StateError( + 'ProviderTileCacheManager.init() must be called before getOrCreate()', + ); + } final key = '$providerId/$tileTypeId'; if (_stores.containsKey(key)) return _stores[key]!; diff --git a/lib/services/provider_tile_cache_store.dart b/lib/services/provider_tile_cache_store.dart index bf23e15..192a13a 100644 --- a/lib/services/provider_tile_cache_store.dart +++ b/lib/services/provider_tile_cache_store.dart @@ -47,7 +47,7 @@ class ProviderTileCacheStore implements MapCachingProvider { @override Future getTile(String url) async { - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -90,7 +90,7 @@ class ProviderTileCacheStore implements MapCachingProvider { }) async { await _ensureDirectory(); - final key = _keyFor(url); + final key = keyFor(url); final tileFile = File(p.join(cacheDirectory, '$key.tile')); final metaFile = File(p.join(cacheDirectory, '$key.meta')); @@ -158,7 +158,8 @@ class ProviderTileCacheStore implements MapCachingProvider { } /// 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); + @visibleForTesting + static String keyFor(String url) => _uuid.v5(Namespace.url.value, url); /// Estimate total cache size (lazy, first call scans directory). Future _getEstimatedSize() async { @@ -301,6 +302,7 @@ class ProviderTileCacheStore implements MapCachingProvider { } _estimatedSize = null; _directoryReady = null; // Allow lazy re-creation + _lastPruneCheck = null; // Reset throttle so next write can trigger eviction } /// Get the current estimated cache size in bytes. diff --git a/lib/services/service_policy.dart b/lib/services/service_policy.dart index 078e7a7..acf8a24 100644 --- a/lib/services/service_policy.dart +++ b/lib/services/service_policy.dart @@ -315,10 +315,12 @@ class ServiceRateLimiter { static Future 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. + // Concurrency: acquire a semaphore slot first so that at most + // [policy.maxConcurrentRequests] callers proceed concurrently. + // The min-interval check below is only race-free when + // maxConcurrentRequests == 1 (currently only Nominatim). For services + // with higher concurrency the interval is approximate, which is + // acceptable — their policies don't specify a min interval. _Semaphore? semaphore; if (policy.maxConcurrentRequests > 0) { semaphore = _semaphores.putIfAbsent( diff --git a/lib/widgets/map/tile_layer_manager.dart b/lib/widgets/map/tile_layer_manager.dart index fcf74d9..78cda5e 100644 --- a/lib/widgets/map/tile_layer_manager.dart +++ b/lib/widgets/map/tile_layer_manager.dart @@ -225,7 +225,24 @@ class TileLayerManager { ); } + /// Build a config fingerprint for drift detection. + /// + /// If any of these fields change (e.g. user edits the URL template or + /// rotates an API key) the cached [DeflockTileProvider] must be replaced. + static String _configFingerprint( + models.TileProvider provider, + models.TileType tileType, + ) => + '${provider.id}/${tileType.id}' + '|${tileType.urlTemplate}' + '|${tileType.maxZoom}' + '|${provider.apiKey ?? ''}'; + /// Get or create a [DeflockTileProvider] for the given provider/type. + /// + /// Providers are cached by `providerId/tileTypeId`. If the effective config + /// (URL template, max zoom, API key) has changed since the provider was + /// created, the stale instance is shut down and replaced. DeflockTileProvider _getOrCreateProvider({ required models.TileProvider? selectedProvider, required models.TileType? selectedTileType, @@ -247,6 +264,19 @@ class TileLayerManager { } final key = '${selectedProvider.id}/${selectedTileType.id}'; + final fingerprint = _configFingerprint(selectedProvider, selectedTileType); + + // Check for config drift: if the provider exists but its config has + // changed, shut down the stale instance so a fresh one is created below. + final existing = _providers[key]; + if (existing != null && existing.configFingerprint != fingerprint) { + debugPrint( + '[TileLayerManager] Config changed for $key — replacing provider', + ); + existing.shutdown(); + _providers.remove(key); + } + return _providers.putIfAbsent(key, () { final cachingProvider = ProviderTileCacheManager.isInitialized ? ProviderTileCacheManager.getOrCreate( @@ -267,6 +297,7 @@ class TileLayerManager { apiKey: selectedProvider.apiKey, cachingProvider: cachingProvider, onNetworkSuccess: onTileLoadSuccess, + configFingerprint: fingerprint, ); }); } diff --git a/test/services/provider_tile_cache_store_test.dart b/test/services/provider_tile_cache_store_test.dart index c1906f4..e0f974d 100644 --- a/test/services/provider_tile_cache_store_test.dart +++ b/test/services/provider_tile_cache_store_test.dart @@ -359,8 +359,8 @@ void main() { group('ProviderTileCacheStore eviction', () { /// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes. - /// Uses small delays between writes so modification times are - /// distinguishable for oldest-modified ordering. + /// Sets deterministic modification times (1 second apart) so eviction + /// ordering is stable across platforms without relying on wall-clock delays. Future fillCache( ProviderTileCacheStore store, { required int count, @@ -373,14 +373,22 @@ void main() { lastModified: null, etag: null, ); + final baseTime = DateTime.utc(2026, 1, 1); for (var i = 0; i < count; i++) { await store.putTile( url: 'https://tile.example.com/$prefix$i.png', metadata: metadata, bytes: bytes, ); - // Small delay so modification times are distinguishable for eviction order - await Future.delayed(const Duration(milliseconds: 10)); + // Set deterministic mtime so eviction order is stable across platforms. + final key = ProviderTileCacheStore.keyFor( + 'https://tile.example.com/$prefix$i.png', + ); + final tileFile = File(p.join(store.cacheDirectory, '$key.tile')); + final metaFile = File(p.join(store.cacheDirectory, '$key.meta')); + final mtime = baseTime.add(Duration(seconds: i)); + await tileFile.setLastModified(mtime); + await metaFile.setLastModified(mtime); } } diff --git a/test/widgets/map/tile_layer_manager_test.dart b/test/widgets/map/tile_layer_manager_test.dart index 43a80b2..3a9183b 100644 --- a/test/widgets/map/tile_layer_manager_test.dart +++ b/test/widgets/map/tile_layer_manager_test.dart @@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:deflockapp/models/tile_provider.dart' as models; import 'package:deflockapp/services/deflock_tile_provider.dart'; import 'package:deflockapp/widgets/map/tile_layer_manager.dart'; @@ -419,6 +420,136 @@ void main() { }); }); + group('TileLayerManager config drift detection', () { + late TileLayerManager manager; + + setUp(() { + manager = TileLayerManager(); + }); + + tearDown(() { + manager.dispose(); + }); + + models.TileProvider makeProvider({String? apiKey}) => models.TileProvider( + id: 'test_provider', + name: 'Test', + apiKey: apiKey, + tileTypes: [], + ); + + models.TileType makeTileType({ + String urlTemplate = 'https://example.com/{z}/{x}/{y}.png', + int maxZoom = 18, + }) => + models.TileType( + id: 'test_tile', + name: 'Test', + urlTemplate: urlTemplate, + attribution: 'Test', + maxZoom: maxZoom, + ); + + test('returns same provider for identical config', () { + final provider = makeProvider(); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isTrue, + reason: 'Same config should return the cached provider instance', + ); + }); + + test('replaces provider when urlTemplate changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType( + urlTemplate: 'https://old.example.com/{z}/{x}/{y}.png', + ); + final tileTypeV2 = makeTileType( + urlTemplate: 'https://new.example.com/{z}/{x}/{y}.png', + ); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed urlTemplate should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).tileType.urlTemplate, + 'https://new.example.com/{z}/{x}/{y}.png', + ); + }); + + test('replaces provider when apiKey changes', () { + final providerV1 = makeProvider(apiKey: 'old_key'); + final providerV2 = makeProvider(apiKey: 'new_key'); + final tileType = makeTileType(); + + final layer1 = manager.buildTileLayer( + selectedProvider: providerV1, + selectedTileType: tileType, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: providerV2, + selectedTileType: tileType, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed apiKey should create a new provider', + ); + expect( + (layer2.tileProvider as DeflockTileProvider).apiKey, + 'new_key', + ); + }); + + test('replaces provider when maxZoom changes', () { + final provider = makeProvider(); + final tileTypeV1 = makeTileType(maxZoom: 18); + final tileTypeV2 = makeTileType(maxZoom: 20); + + final layer1 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV1, + ) as TileLayer; + + final layer2 = manager.buildTileLayer( + selectedProvider: provider, + selectedTileType: tileTypeV2, + ) as TileLayer; + + expect( + identical(layer1.tileProvider, layer2.tileProvider), + isFalse, + reason: 'Changed maxZoom should create a new provider', + ); + }); + }); + group('TileLayerManager error-type filtering', () { late TileLayerManager manager; late MockTileImage mockTile;