mirror of
https://github.com/FoggedLens/deflock-app.git
synced 2026-10-11 18:28:48 +02:00
Detect config drift in cached tile providers and replace stale instances
When a user edits a tile type's URL template, max zoom, or API key without changing IDs, the cached DeflockTileProvider would keep the old frozen config. Now _getOrCreateProvider() computes a config fingerprint and replaces the provider when drift is detected. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
1 parent
f3f40f36ef
commit
91e5177056
7 files changed
+197
-13
No files matched your search
@@ -52,6 +52,10 @@ class DeflockTileProvider extends NetworkTileProvider {
|
|||||||
final models.TileType tileType;
|
final models.TileType tileType;
|
||||||
final String? apiKey;
|
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
|
/// 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
|
/// to super for the common path — we keep a reference here so we can also
|
||||||
/// use it in [DeflockOfflineTileImageProvider].
|
/// use it in [DeflockOfflineTileImageProvider].
|
||||||
@@ -69,6 +73,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
|||||||
this.apiKey,
|
this.apiKey,
|
||||||
MapCachingProvider? cachingProvider,
|
MapCachingProvider? cachingProvider,
|
||||||
this.onNetworkSuccess,
|
this.onNetworkSuccess,
|
||||||
|
this.configFingerprint = '',
|
||||||
}) : _sharedHttpClient = httpClient,
|
}) : _sharedHttpClient = httpClient,
|
||||||
_cachingProvider = cachingProvider,
|
_cachingProvider = cachingProvider,
|
||||||
super(
|
super(
|
||||||
@@ -87,6 +92,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
|||||||
String? apiKey,
|
String? apiKey,
|
||||||
MapCachingProvider? cachingProvider,
|
MapCachingProvider? cachingProvider,
|
||||||
VoidCallback? onNetworkSuccess,
|
VoidCallback? onNetworkSuccess,
|
||||||
|
String configFingerprint = '',
|
||||||
}) {
|
}) {
|
||||||
final client = UserAgentClient(RetryClient(Client()));
|
final client = UserAgentClient(RetryClient(Client()));
|
||||||
return DeflockTileProvider._(
|
return DeflockTileProvider._(
|
||||||
@@ -96,6 +102,7 @@ class DeflockTileProvider extends NetworkTileProvider {
|
|||||||
apiKey: apiKey,
|
apiKey: apiKey,
|
||||||
cachingProvider: cachingProvider,
|
cachingProvider: cachingProvider,
|
||||||
onNetworkSuccess: onNetworkSuccess,
|
onNetworkSuccess: onNetworkSuccess,
|
||||||
|
configFingerprint: configFingerprint,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -37,8 +37,11 @@ class ProviderTileCacheManager {
|
|||||||
required ServicePolicy policy,
|
required ServicePolicy policy,
|
||||||
int? maxCacheBytes,
|
int? maxCacheBytes,
|
||||||
}) {
|
}) {
|
||||||
assert(_baseCacheDir != null,
|
if (_baseCacheDir == null) {
|
||||||
'ProviderTileCacheManager.init() must be called before getOrCreate()');
|
throw StateError(
|
||||||
|
'ProviderTileCacheManager.init() must be called before getOrCreate()',
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
final key = '$providerId/$tileTypeId';
|
final key = '$providerId/$tileTypeId';
|
||||||
if (_stores.containsKey(key)) return _stores[key]!;
|
if (_stores.containsKey(key)) return _stores[key]!;
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
Future<CachedMapTile?> getTile(String url) async {
|
Future<CachedMapTile?> getTile(String url) async {
|
||||||
final key = _keyFor(url);
|
final key = keyFor(url);
|
||||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
||||||
|
|
||||||
@@ -90,7 +90,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
}) async {
|
}) async {
|
||||||
await _ensureDirectory();
|
await _ensureDirectory();
|
||||||
|
|
||||||
final key = _keyFor(url);
|
final key = keyFor(url);
|
||||||
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
final tileFile = File(p.join(cacheDirectory, '$key.tile'));
|
||||||
final metaFile = File(p.join(cacheDirectory, '$key.meta'));
|
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).
|
/// 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).
|
/// Estimate total cache size (lazy, first call scans directory).
|
||||||
Future<int> _getEstimatedSize() async {
|
Future<int> _getEstimatedSize() async {
|
||||||
@@ -301,6 +302,7 @@ class ProviderTileCacheStore implements MapCachingProvider {
|
|||||||
}
|
}
|
||||||
_estimatedSize = null;
|
_estimatedSize = null;
|
||||||
_directoryReady = null; // Allow lazy re-creation
|
_directoryReady = null; // Allow lazy re-creation
|
||||||
|
_lastPruneCheck = null; // Reset throttle so next write can trigger eviction
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get the current estimated cache size in bytes.
|
/// Get the current estimated cache size in bytes.
|
||||||
|
|||||||
@@ -315,10 +315,12 @@ class ServiceRateLimiter {
|
|||||||
static Future<void> acquire(ServiceType service) async {
|
static Future<void> acquire(ServiceType service) async {
|
||||||
final policy = ServicePolicyResolver.resolveByType(service);
|
final policy = ServicePolicyResolver.resolveByType(service);
|
||||||
|
|
||||||
// Concurrency: acquire semaphore slot first, so only one caller at a
|
// Concurrency: acquire a semaphore slot first so that at most
|
||||||
// time proceeds to the rate-limit check. This prevents concurrent
|
// [policy.maxConcurrentRequests] callers proceed concurrently.
|
||||||
// callers from bypassing the min interval when _lastRequestTime is
|
// The min-interval check below is only race-free when
|
||||||
// still null or stale.
|
// 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;
|
_Semaphore? semaphore;
|
||||||
if (policy.maxConcurrentRequests > 0) {
|
if (policy.maxConcurrentRequests > 0) {
|
||||||
semaphore = _semaphores.putIfAbsent(
|
semaphore = _semaphores.putIfAbsent(
|
||||||
|
|||||||
@@ -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.
|
/// 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({
|
DeflockTileProvider _getOrCreateProvider({
|
||||||
required models.TileProvider? selectedProvider,
|
required models.TileProvider? selectedProvider,
|
||||||
required models.TileType? selectedTileType,
|
required models.TileType? selectedTileType,
|
||||||
@@ -247,6 +264,19 @@ class TileLayerManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final key = '${selectedProvider.id}/${selectedTileType.id}';
|
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, () {
|
return _providers.putIfAbsent(key, () {
|
||||||
final cachingProvider = ProviderTileCacheManager.isInitialized
|
final cachingProvider = ProviderTileCacheManager.isInitialized
|
||||||
? ProviderTileCacheManager.getOrCreate(
|
? ProviderTileCacheManager.getOrCreate(
|
||||||
@@ -267,6 +297,7 @@ class TileLayerManager {
|
|||||||
apiKey: selectedProvider.apiKey,
|
apiKey: selectedProvider.apiKey,
|
||||||
cachingProvider: cachingProvider,
|
cachingProvider: cachingProvider,
|
||||||
onNetworkSuccess: onTileLoadSuccess,
|
onNetworkSuccess: onTileLoadSuccess,
|
||||||
|
configFingerprint: fingerprint,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -359,8 +359,8 @@ void main() {
|
|||||||
|
|
||||||
group('ProviderTileCacheStore eviction', () {
|
group('ProviderTileCacheStore eviction', () {
|
||||||
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
|
/// Helper: populate cache with [count] tiles, each [bytesPerTile] bytes.
|
||||||
/// Uses small delays between writes so modification times are
|
/// Sets deterministic modification times (1 second apart) so eviction
|
||||||
/// distinguishable for oldest-modified ordering.
|
/// ordering is stable across platforms without relying on wall-clock delays.
|
||||||
Future<void> fillCache(
|
Future<void> fillCache(
|
||||||
ProviderTileCacheStore store, {
|
ProviderTileCacheStore store, {
|
||||||
required int count,
|
required int count,
|
||||||
@@ -373,14 +373,22 @@ void main() {
|
|||||||
lastModified: null,
|
lastModified: null,
|
||||||
etag: null,
|
etag: null,
|
||||||
);
|
);
|
||||||
|
final baseTime = DateTime.utc(2026, 1, 1);
|
||||||
for (var i = 0; i < count; i++) {
|
for (var i = 0; i < count; i++) {
|
||||||
await store.putTile(
|
await store.putTile(
|
||||||
url: 'https://tile.example.com/$prefix$i.png',
|
url: 'https://tile.example.com/$prefix$i.png',
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
bytes: bytes,
|
bytes: bytes,
|
||||||
);
|
);
|
||||||
// Small delay so modification times are distinguishable for eviction order
|
// Set deterministic mtime so eviction order is stable across platforms.
|
||||||
await Future<void>.delayed(const Duration(milliseconds: 10));
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:flutter_map/flutter_map.dart';
|
|||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:mocktail/mocktail.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/services/deflock_tile_provider.dart';
|
||||||
import 'package:deflockapp/widgets/map/tile_layer_manager.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', () {
|
group('TileLayerManager error-type filtering', () {
|
||||||
late TileLayerManager manager;
|
late TileLayerManager manager;
|
||||||
late MockTileImage mockTile;
|
late MockTileImage mockTile;
|
||||||
|
|||||||
Reference in new issue
Block a user