Files
deflock-app/lib/services/deflock_tile_provider.dart
Doug Borg 91e5177056 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>
2026-03-07 12:34:01 -07:00

472 lines
17 KiB
Dart

import 'dart:async';
import 'dart:io';
import 'dart:ui';
import 'package:flutter_map/flutter_map.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
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 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;
/// Frozen config for this provider instance.
final String providerId;
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].
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,
this.configFingerprint = '',
}) : _sharedHttpClient = httpClient,
_cachingProvider = cachingProvider,
super(
httpClient: httpClient,
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({
required String providerId,
required models.TileType tileType,
String? apiKey,
MapCachingProvider? cachingProvider,
VoidCallback? onNetworkSuccess,
String configFingerprint = '',
}) {
final client = UserAgentClient(RetryClient(Client()));
return DeflockTileProvider._(
httpClient: client,
providerId: providerId,
tileType: tileType,
apiKey: apiKey,
cachingProvider: cachingProvider,
onNetworkSuccess: onNetworkSuccess,
configFingerprint: configFingerprint,
);
}
@override
String getTileUrl(TileCoordinates coordinates, TileLayer options) {
return tileType.getTileUrl(
coordinates.z,
coordinates.x,
coordinates.y,
apiKey: apiKey,
);
}
@override
ImageProvider getImageWithCancelLoadingSupport(
TileCoordinates coordinates,
TileLayer options,
Future<void> cancelLoading,
) {
if (!_shouldCheckOfflineCache(coordinates.z)) {
// Common path: no offline areas — delegate to NetworkTileProvider's
// full pipeline (disk cache, ETag, RetryClient, abort support).
return super.getImageWithCancelLoadingSupport(
coordinates,
options,
cancelLoading,
);
}
// Offline-first path: check local tiles first, fall back to network.
return DeflockOfflineTileImageProvider(
coordinates: coordinates,
options: options,
httpClient: _sharedHttpClient,
headers: headers,
cancelLoading: cancelLoading,
isOfflineOnly: AppState.instance.offlineMode,
providerId: providerId,
tileTypeId: tileType.id,
tileUrl: getTileUrl(coordinates, options),
cachingProvider: _cachingProvider,
onNetworkSuccess: onNetworkSuccess,
);
}
/// Determine if we should check offline cache for this tile request.
/// Only returns true if:
/// 1. We're in offline mode (forced), OR
/// 2. We have offline areas for the current provider/type
///
/// This avoids the offline-first path (and its filesystem searches) when
/// browsing online with providers that have no offline areas.
bool _shouldCheckOfflineCache(int zoom) {
// Always use offline path in offline mode
if (AppState.instance.offlineMode) {
return true;
}
// For online mode, only use offline path if we have relevant offline data
// 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.hasOfflineAreasForProviderAtZoom(
providerId,
tileType.id,
zoom,
);
}
@override
Future<void> dispose() async {
// 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.
///
/// 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;
final TileLayer options;
final Client httpClient;
final Map<String, String> headers;
final Future<void> cancelLoading;
final bool isOfflineOnly;
final String providerId;
final String tileTypeId;
final String tileUrl;
final MapCachingProvider? cachingProvider;
final VoidCallback? onNetworkSuccess;
const DeflockOfflineTileImageProvider({
required this.coordinates,
required this.options,
required this.httpClient,
required this.headers,
required this.cancelLoading,
required this.isOfflineOnly,
required this.providerId,
required this.tileTypeId,
required this.tileUrl,
this.cachingProvider,
this.onNetworkSuccess,
});
@override
Future<DeflockOfflineTileImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture<DeflockOfflineTileImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
return MultiFrameImageStreamCompleter(
// 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,
StreamController<ImageChunkEvent> chunkEvents,
) async {
Future<Codec> decodeBytes(Uint8List bytes) =>
ImmutableBuffer.fromUint8List(bytes).then(decode);
// 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 {
if (isOfflineOnly) {
return await _loadOffline(decodeBytes, cancelled);
}
return await _loadOnline(decodeBytes, cancelled);
} catch (e) {
// 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();
}
// 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.isOfflineOnly == isOfflineOnly;
}
@override
int get hashCode =>
Object.hash(coordinates, providerId, tileTypeId, isOfflineOnly);
}