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:
Doug Borg
2026-03-03 14:33:26 -07:00
parent be446fbcbc
commit 2d92214bed
36 changed files with 3940 additions and 351 deletions
+74 -19
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import 'package:flutter_map_animations/flutter_map_animations.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../app_state.dart';
import '../../dev_config.dart';
@@ -26,16 +27,63 @@ class MapOverlays extends StatelessWidget {
this.onSearchPressed,
});
/// Show full attribution text in a dialog
/// Show full attribution text in a dialog with license link.
void _showAttributionDialog(BuildContext context, String attribution) {
final locService = LocalizationService.instance;
// Get the license URL from the current tile provider's service policy
final appState = AppState.instance;
final tileType = appState.selectedTileType;
final attributionUrl = tileType?.servicePolicy.attributionUrl;
showDialog(
context: context,
builder: (context) => AlertDialog(
title: Text(locService.t('mapTiles.attribution')),
content: SelectableText(
attribution,
style: const TextStyle(fontSize: 14),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(
attribution,
style: const TextStyle(fontSize: 14),
),
if (attributionUrl != null) ...[
const SizedBox(height: 12),
Semantics(
link: true,
label: locService.t('mapTiles.openLicense', params: [attributionUrl]),
child: InkWell(
onTap: () async {
try {
final uri = Uri.parse(attributionUrl);
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
);
}
} catch (_) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(locService.t('mapTiles.couldNotOpenLink'))),
);
}
}
},
child: Text(
attributionUrl,
style: TextStyle(
fontSize: 13,
color: Theme.of(context).colorScheme.primary,
decoration: TextDecoration.underline,
),
),
),
),
],
],
),
actions: [
TextButton(
@@ -125,23 +173,30 @@ class MapOverlays extends StatelessWidget {
Positioned(
bottom: bottomPositionFromButtonBar(kAttributionSpacingAboveButtonBar, safeArea.bottom),
left: leftPositionWithSafeArea(10, safeArea),
child: GestureDetector(
onTap: () => _showAttributionDialog(context, attribution!),
child: Container(
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
child: Semantics(
button: true,
label: LocalizationService.instance.t('mapTiles.mapAttribution', params: [attribution!]),
child: Material(
color: Theme.of(context).colorScheme.surface.withValues(alpha: 0.9),
borderRadius: BorderRadius.circular(4),
child: InkWell(
borderRadius: BorderRadius.circular(4),
),
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
constraints: const BoxConstraints(maxWidth: 250),
child: Text(
attribution!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
onTap: () => _showAttributionDialog(context, attribution!),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 250),
child: Text(
attribution!,
style: TextStyle(
fontSize: 11,
color: Theme.of(context).colorScheme.onSurface,
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
overflow: TextOverflow.ellipsis,
maxLines: 1,
),
),
),
+197 -24
View File
@@ -1,68 +1,124 @@
import 'dart:async';
import 'dart:math';
import 'package:flutter/material.dart';
import 'package:flutter_map/flutter_map.dart';
import '../../models/tile_provider.dart' as models;
import '../../services/deflock_tile_provider.dart';
import '../../services/provider_tile_cache_manager.dart';
/// Manages tile layer creation, caching, and provider switching.
/// Uses DeFlock's custom tile provider for clean integration.
/// Manages tile layer creation with per-provider caching and provider switching.
///
/// Each tile provider/type combination gets its own [DeflockTileProvider]
/// instance with isolated caching (separate cache directory, configurable size
/// limit, and policy-driven TTL enforcement). Providers are created lazily on
/// first use and cached for instant switching.
class TileLayerManager {
DeflockTileProvider? _tileProvider;
final Map<String, DeflockTileProvider> _providers = {};
int _mapRebuildKey = 0;
String? _lastProviderId;
String? _lastTileTypeId;
bool? _lastOfflineMode;
/// Get the current map rebuild key for cache busting
/// Stream that triggers flutter_map to drop all tiles and reload.
/// Fired after a debounced delay when tile errors are detected.
final StreamController<void> _resetController =
StreamController<void>.broadcast();
/// Debounce timer for scheduling a tile reset after errors.
Timer? _retryTimer;
/// Current retry delay — starts at [_minRetryDelay] and doubles on each
/// retry cycle (capped at [_maxRetryDelay]). Resets to [_minRetryDelay]
/// when a tile loads successfully.
Duration _retryDelay = const Duration(seconds: 2);
static const _minRetryDelay = Duration(seconds: 2);
static const _maxRetryDelay = Duration(seconds: 60);
/// Get the current map rebuild key for cache busting.
int get mapRebuildKey => _mapRebuildKey;
/// Initialize the tile layer manager
/// Current retry delay (exposed for testing).
@visibleForTesting
Duration get retryDelay => _retryDelay;
/// Stream of reset events (exposed for testing).
@visibleForTesting
Stream<void> get resetStream => _resetController.stream;
/// Initialize the tile layer manager.
///
/// [ProviderTileCacheManager.init] is called in main() before any widgets
/// build, so this is a no-op retained for API compatibility.
void initialize() {
// Don't create tile provider here - create it fresh for each build
// Cache directory is already resolved in main().
}
/// Dispose of resources
/// Dispose of all provider resources.
///
/// Synchronous to match Flutter's [State.dispose] contract. Calls
/// [DeflockTileProvider.shutdown] to permanently close each provider's HTTP
/// client. (We don't call provider.dispose() here — flutter_map already
/// called it when the TileLayer widget was removed, and it's safe to call
/// again but unnecessary.)
void dispose() {
_tileProvider?.dispose();
_retryTimer?.cancel();
_resetController.close();
for (final provider in _providers.values) {
provider.shutdown();
}
_providers.clear();
}
/// Check if cache should be cleared and increment rebuild key if needed.
/// Returns true if cache was cleared (map should be rebuilt).
bool checkAndClearCacheIfNeeded({
required String? currentProviderId,
required String? currentTileTypeId,
required bool currentOfflineMode,
}) {
bool shouldClear = false;
String? reason;
if ((_lastTileTypeId != null && _lastTileTypeId != currentTileTypeId)) {
if (_lastProviderId != currentProviderId) {
reason = 'provider ($currentProviderId)';
shouldClear = true;
} else if (_lastTileTypeId != currentTileTypeId) {
reason = 'tile type ($currentTileTypeId)';
shouldClear = true;
} else if ((_lastOfflineMode != null && _lastOfflineMode != currentOfflineMode)) {
} else if (_lastOfflineMode != currentOfflineMode) {
reason = 'offline mode ($currentOfflineMode)';
shouldClear = true;
}
if (shouldClear) {
// Force map rebuild with new key to bust flutter_map cache
// Force map rebuild with new key to bust flutter_map cache.
// We don't dispose providers here — they're reusable across switches.
_mapRebuildKey++;
// Dispose old provider before creating a fresh one (closes HTTP client)
_tileProvider?.dispose();
_tileProvider = null;
// Reset backoff so the new provider starts with a clean slate.
// Cancel any pending retry timer — it belongs to the old provider's errors.
_retryDelay = _minRetryDelay;
_retryTimer?.cancel();
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
}
_lastProviderId = currentProviderId;
_lastTileTypeId = currentTileTypeId;
_lastOfflineMode = currentOfflineMode;
return shouldClear;
}
/// Clear the tile request queue (call after cache clear)
/// Clear the tile request queue (call after cache clear).
///
/// In the old architecture this incremented [_mapRebuildKey] a second time
/// to force a rebuild after the provider was disposed and recreated. With
/// per-provider caching, [checkAndClearCacheIfNeeded] already increments the
/// key, so this is now a no-op. Kept for API compatibility with map_view.
void clearTileQueue() {
// With NetworkTileProvider, clearing is handled by FlutterMap's internal cache
// We just need to increment the rebuild key to bust the cache
_mapRebuildKey++;
debugPrint('[TileLayerManager] Cache cleared - rebuilding map $_mapRebuildKey');
// No-op: checkAndClearCacheIfNeeded() already incremented _mapRebuildKey.
}
/// Clear tile queue immediately (for zoom changes, etc.)
@@ -70,19 +126,85 @@ class TileLayerManager {
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
}
/// Clear only tiles that are no longer visible in the current bounds
/// Clear only tiles that are no longer visible in the current bounds.
void clearStaleRequests({required LatLngBounds currentBounds}) {
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
}
/// Called by flutter_map when a tile fails to load. Schedules a debounced
/// reset so that all failed tiles get retried after the burst of errors
/// settles down. Uses exponential backoff: 2s → 4s → 8s → … → 60s cap.
///
/// Skips retry for [TileLoadCancelledException] (tile scrolled off screen)
/// and [TileNotAvailableOfflineException] (no cached data, retrying won't
/// help without network).
@visibleForTesting
void onTileLoadError(
TileImage tile,
Object error,
StackTrace? stackTrace,
) {
// Cancelled tiles are already gone — no retry needed.
if (error is TileLoadCancelledException) return;
// Offline misses won't resolve by retrying — tile isn't cached.
if (error is TileNotAvailableOfflineException) return;
debugPrint(
'[TileLayerManager] Tile error at '
'${tile.coordinates.z}/${tile.coordinates.x}/${tile.coordinates.y}, '
'scheduling retry in ${_retryDelay.inSeconds}s',
);
scheduleRetry();
}
/// Schedule a debounced tile reset with exponential backoff.
///
/// Cancels any pending retry timer and starts a new one at the current
/// [_retryDelay]. After the timer fires, [_retryDelay] doubles (capped
/// at [_maxRetryDelay]).
@visibleForTesting
void scheduleRetry() {
_retryTimer?.cancel();
_retryTimer = Timer(_retryDelay, () {
if (!_resetController.isClosed) {
debugPrint('[TileLayerManager] Firing tile reset to retry failed tiles');
_resetController.add(null);
}
// Back off for next failure cycle
_retryDelay = Duration(
milliseconds: min(
_retryDelay.inMilliseconds * 2,
_maxRetryDelay.inMilliseconds,
),
);
});
}
/// Reset backoff to minimum delay. Called when a tile loads successfully
/// via the offline-first path, indicating connectivity has been restored.
///
/// Note: the common path (`NetworkTileImageProvider`) does not call this,
/// so backoff resets only when the offline-first path succeeds over the
/// network. In practice this is fine — the common path's `RetryClient`
/// handles its own retries, and the reset stream only retries tiles that
/// flutter_map has already marked as `loadError`.
void onTileLoadSuccess() {
_retryDelay = _minRetryDelay;
}
/// Build tile layer widget with current provider and type.
/// Uses DeFlock's custom tile provider for clean integration with our offline/online system.
///
/// Gets or creates a [DeflockTileProvider] for the given provider/type
/// combination, each with its own isolated cache.
Widget buildTileLayer({
required models.TileProvider? selectedProvider,
required models.TileType? selectedTileType,
}) {
// Create a fresh tile provider instance if we don't have one or cache was cleared
_tileProvider ??= DeflockTileProvider();
final tileProvider = _getOrCreateProvider(
selectedProvider: selectedProvider,
selectedTileType: selectedTileType,
);
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
// override handles the real URL generation; flutter_map uses urlTemplate
@@ -94,7 +216,58 @@ class TileLayerManager {
urlTemplate: urlTemplate,
userAgentPackageName: 'me.deflock.deflockapp',
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
tileProvider: _tileProvider!,
tileProvider: tileProvider,
// Wire the reset stream so failed tiles get retried after a delay.
reset: _resetController.stream,
errorTileCallback: onTileLoadError,
// Clean up error tiles when they scroll off screen.
evictErrorTileStrategy: EvictErrorTileStrategy.notVisible,
);
}
/// Get or create a [DeflockTileProvider] for the given provider/type.
DeflockTileProvider _getOrCreateProvider({
required models.TileProvider? selectedProvider,
required models.TileType? selectedTileType,
}) {
if (selectedProvider == null || selectedTileType == null) {
// No provider configured — return a fallback with default config.
return _providers.putIfAbsent(
'_fallback',
() => DeflockTileProvider(
providerId: 'unknown',
tileType: models.TileType(
id: 'unknown',
name: 'Unknown',
urlTemplate: 'https://unknown.invalid/tiles/{z}/{x}/{y}',
attribution: '',
),
),
);
}
final key = '${selectedProvider.id}/${selectedTileType.id}';
return _providers.putIfAbsent(key, () {
final cachingProvider = ProviderTileCacheManager.isInitialized
? ProviderTileCacheManager.getOrCreate(
providerId: selectedProvider.id,
tileTypeId: selectedTileType.id,
policy: selectedTileType.servicePolicy,
)
: null;
debugPrint(
'[TileLayerManager] Creating provider for $key '
'(cache: ${cachingProvider != null ? "enabled" : "disabled"})',
);
return DeflockTileProvider(
providerId: selectedProvider.id,
tileType: selectedTileType,
apiKey: selectedProvider.apiKey,
cachingProvider: cachingProvider,
onNetworkSuccess: onTileLoadSuccess,
);
});
}
}