Delegate network tile fetching to NetworkTileProvider

Replace our custom tile pipeline (fetchRemoteTile / _SimpleSemaphore /
exponential backoff) with flutter_map's built-in NetworkTileProvider,
gaining persistent disk cache, ETag revalidation, RetryClient, and
obsolete request aborting for free.

DeflockTileProvider now extends NetworkTileProvider and overrides
getTileUrl() to route through TileType.getTileUrl() (quadkey,
subdomains, API keys). getImageWithCancelLoadingSupport() routes
between two paths at runtime: the common network path (super) when
no offline areas exist, and a DeflockOfflineTileImageProvider for
offline-first when they do.

- Delete tiles_from_remote.dart (semaphore, retry loop, spatial helpers)
- Simplify MapDataProvider._fetchRemoteTileFromCurrentProvider to plain
  http.get (only used by offline area downloader now)
- Remove dead clearTileQueue/clearTileQueueSelective from MapDataProvider
- Remove 7 tile fetch constants from dev_config.dart
- TileLayerManager now disposes provider on cache clear and uses actual
  urlTemplate for cache key generation
- 9 new tests covering URL delegation, routing, and equality

Closes #87 Phase 2.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Doug Borg
2026-02-24 19:39:09 -07:00
parent 9448305738
commit 8983939b05
7 changed files with 465 additions and 486 deletions
-9
View File
@@ -155,15 +155,6 @@ const double kPinchZoomThreshold = 0.2; // How much pinch required to start zoom
const double kPinchMoveThreshold = 30.0; // How much drag required for two-finger pan (default 40.0)
const double kRotationThreshold = 6.0; // Degrees of rotation required before map actually rotates (Google Maps style)
// Tile fetch configuration (brutalist approach: simple, configurable, unlimited retries)
const int kTileFetchConcurrentThreads = 8; // Reduced from 10 to 8 for better cross-platform performance
const int kTileFetchInitialDelayMs = 150; // Reduced from 200ms for faster retries
const double kTileFetchBackoffMultiplier = 1.4; // Slightly reduced for faster recovery
const int kTileFetchMaxDelayMs = 4000; // Reduced from 5000ms for faster max retry
const int kTileFetchRandomJitterMs = 50; // Reduced jitter for more predictable timing
const int kTileFetchMaxQueueSize = 100; // Reasonable queue size to prevent memory bloat
// Note: Removed max attempts - tiles retry indefinitely until they succeed or are canceled
// User download max zoom span (user can download up to kMaxUserDownloadZoomSpan zooms above min)
const int kMaxUserDownloadZoomSpan = 7;
+215 -101
View File
@@ -4,154 +4,268 @@ 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 'map_data_provider.dart';
import 'http_client.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'offline_area_service.dart';
/// Custom tile provider that integrates with DeFlock's offline/online architecture.
///
/// This replaces the complex HTTP interception approach with a clean TileProvider
/// implementation that directly interfaces with our MapDataProvider system.
class DeflockTileProvider extends TileProvider {
final MapDataProvider _mapDataProvider = MapDataProvider();
/// 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.
///
/// 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.
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,
super(
httpClient: httpClient,
silenceExceptions: true,
);
factory DeflockTileProvider() {
final client = UserAgentClient(RetryClient(Client()));
return DeflockTileProvider._(httpClient: client);
}
@override
ImageProvider getImage(TileCoordinates coordinates, TileLayer options) {
// Get current provider info to include in cache key
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(
coordinates.z,
coordinates.x,
coordinates.y,
apiKey: selectedProvider.apiKey,
);
}
@override
ImageProvider getImageWithCancelLoadingSupport(
TileCoordinates coordinates,
TileLayer options,
Future<void> cancelLoading,
) {
if (!_shouldCheckOfflineCache()) {
// 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.
final appState = AppState.instance;
final providerId = appState.selectedTileProvider?.id ?? 'unknown';
final tileTypeId = appState.selectedTileType?.id ?? 'unknown';
return DeflockTileImageProvider(
return DeflockOfflineTileImageProvider(
coordinates: coordinates,
options: options,
mapDataProvider: _mapDataProvider,
httpClient: _sharedHttpClient,
headers: headers,
cancelLoading: cancelLoading,
isOfflineOnly: appState.offlineMode,
providerId: providerId,
tileTypeId: tileTypeId,
tileUrl: getTileUrl(coordinates, options),
);
}
/// 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() {
final appState = AppState.instance;
// Always use offline path in offline mode
if (appState.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;
}
final offlineService = OfflineAreaService();
return offlineService.hasOfflineAreasForProvider(
currentProvider.id,
currentTileType.id,
);
}
@override
Future<void> dispose() async {
try {
await super.dispose();
} finally {
_sharedHttpClient.close();
}
}
}
/// Image provider that fetches tiles through our MapDataProvider.
///
/// This handles the actual tile fetching using our existing offline/online
/// routing logic without any HTTP interception complexity.
class DeflockTileImageProvider extends ImageProvider<DeflockTileImageProvider> {
/// 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).
class DeflockOfflineTileImageProvider
extends ImageProvider<DeflockOfflineTileImageProvider> {
final TileCoordinates coordinates;
final TileLayer options;
final MapDataProvider mapDataProvider;
final Client httpClient;
final Map<String, String> headers;
final Future<void> cancelLoading;
final bool isOfflineOnly;
final String providerId;
final String tileTypeId;
const DeflockTileImageProvider({
final String tileUrl;
const DeflockOfflineTileImageProvider({
required this.coordinates,
required this.options,
required this.mapDataProvider,
required this.httpClient,
required this.headers,
required this.cancelLoading,
required this.isOfflineOnly,
required this.providerId,
required this.tileTypeId,
required this.tileUrl,
});
@override
Future<DeflockTileImageProvider> obtainKey(ImageConfiguration configuration) {
return SynchronousFuture<DeflockTileImageProvider>(this);
Future<DeflockOfflineTileImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture<DeflockOfflineTileImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(DeflockTileImageProvider key, ImageDecoderCallback decode) {
ImageStreamCompleter loadImage(
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
final codecFuture = _loadAsync(key, decode, chunkEvents);
codecFuture.whenComplete(() {
chunkEvents.close();
});
return MultiFrameImageStreamCompleter(
codec: _loadAsync(key, decode, chunkEvents),
codec: codecFuture,
chunkEvents: chunkEvents.stream,
scale: 1.0,
);
}
Future<Codec> _loadAsync(
DeflockTileImageProvider key,
DeflockOfflineTileImageProvider key,
ImageDecoderCallback decode,
StreamController<ImageChunkEvent> chunkEvents,
) async {
Future<Codec> decodeBytes(Uint8List bytes) =>
ImmutableBuffer.fromUint8List(bytes).then(decode);
Future<Codec> transparent() =>
decodeBytes(TileProvider.transparentImage);
try {
// Get current tile provider and type from app state
final appState = AppState.instance;
final selectedProvider = appState.selectedTileProvider;
final selectedTileType = appState.selectedTileType;
if (selectedProvider == null || selectedTileType == null) {
throw Exception('No tile provider configured');
// 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
}
// Smart cache routing: only check offline cache when needed
final MapSource source = _shouldCheckOfflineCache(appState)
? MapSource.auto // Check offline first, then network
: MapSource.remote; // Skip offline cache, go directly to network
final tileBytes = await mapDataProvider.getTile(
z: coordinates.z,
x: coordinates.x,
y: coordinates.y,
source: source,
);
// Decode the image bytes
final buffer = await ImmutableBuffer.fromUint8List(Uint8List.fromList(tileBytes));
return await decode(buffer);
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();
} catch (e) {
// Don't log routine offline misses to avoid console spam
if (!e.toString().contains('offline mode is enabled')) {
debugPrint('[DeflockTileProvider] Failed to load tile ${coordinates.z}/${coordinates.x}/${coordinates.y}: $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})');
}
// Re-throw the exception and let FlutterMap handle missing tiles gracefully
// This is better than trying to provide fallback images
rethrow;
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
.then(decode);
}
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is DeflockTileImageProvider &&
other.coordinates == coordinates &&
other.providerId == providerId &&
other.tileTypeId == tileTypeId;
return other is DeflockOfflineTileImageProvider &&
other.coordinates == coordinates &&
other.providerId == providerId &&
other.tileTypeId == tileTypeId;
}
@override
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
/// Determine if we should check offline cache for this tile request.
/// Only check offline cache if:
/// 1. We're in offline mode (forced), OR
/// 2. We have offline areas for the current provider/type
///
/// This avoids expensive filesystem searches when browsing online
/// with providers that have no offline areas.
bool _shouldCheckOfflineCache(AppState appState) {
// Always check offline cache in offline mode
if (appState.offlineMode) {
return true;
}
// For online mode, only check if we might actually have relevant offline data
final currentProvider = appState.selectedTileProvider;
final currentTileType = appState.selectedTileType;
if (currentProvider == null || currentTileType == null) {
return false;
}
// Quick check: do we have any offline areas for this provider/type?
// This avoids the expensive per-tile filesystem search in fetchLocalTile
final offlineService = OfflineAreaService();
final hasRelevantAreas = offlineService.hasOfflineAreasForProvider(
currentProvider.id,
currentTileType.id,
);
return hasRelevantAreas;
}
}
}
+12 -16
View File
@@ -4,7 +4,7 @@ import 'package:flutter_map/flutter_map.dart';
import '../models/node_profile.dart';
import '../models/osm_node.dart';
import '../app_state.dart';
import 'map_data_submodules/tiles_from_remote.dart';
import 'http_client.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'node_data_manager.dart';
import 'node_spatial_cache.dart';
@@ -24,6 +24,7 @@ class MapDataProvider {
MapDataProvider._();
final NodeDataManager _nodeDataManager = NodeDataManager();
final UserAgentClient _httpClient = UserAgentClient();
bool get isOfflineMode => AppState.instance.offlineMode;
void setOfflineMode(bool enabled) {
@@ -97,29 +98,24 @@ class MapDataProvider {
}
}
/// Fetch remote tile using current provider from AppState
/// Fetch remote tile using current provider from AppState.
/// Only used by offline area downloader — the main tile pipeline now goes
/// through NetworkTileProvider (see DeflockTileProvider).
Future<List<int>> _fetchRemoteTileFromCurrentProvider(int z, int x, int y) async {
final appState = AppState.instance;
final selectedTileType = appState.selectedTileType;
final selectedProvider = appState.selectedTileProvider;
// We guarantee that a provider and tile type are always selected
if (selectedTileType == null || selectedProvider == null) {
throw Exception('No tile provider selected - this should never happen');
}
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
return fetchRemoteTile(z: z, x: x, y: y, url: tileUrl);
}
/// Clear any queued tile requests (call when map view changes significantly)
void clearTileQueue() {
clearRemoteTileQueue();
}
/// Clear only tile requests that are no longer visible in the current bounds
void clearTileQueueSelective(LatLngBounds currentBounds) {
clearRemoteTileQueueSelective(currentBounds);
final tileUrl = selectedTileType.getTileUrl(z, x, y, apiKey: selectedProvider.apiKey);
final resp = await _httpClient.get(Uri.parse(tileUrl));
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
return resp.bodyBytes;
}
throw Exception('Failed to fetch tile $z/$x/$y: status ${resp.statusCode}');
}
/// Add or update nodes in cache (for upload queue integration)
@@ -4,11 +4,21 @@ 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 current provider, or throw if not found.
Future<List<int>> fetchLocalTile({required int z, required int x, required int y}) async {
/// Fetch a tile from the newest offline area that matches the given provider, or throw if not found.
///
/// When [providerId] and [tileTypeId] are supplied the lookup is pinned to
/// those values (avoids a race when the user switches provider mid-flight).
/// Otherwise falls back to the current AppState selection.
Future<List<int>> fetchLocalTile({
required int z,
required int x,
required int y,
String? providerId,
String? tileTypeId,
}) async {
final appState = AppState.instance;
final currentProvider = appState.selectedTileProvider;
final currentTileType = appState.selectedTileType;
final currentProviderId = providerId ?? appState.selectedTileProvider?.id;
final currentTileTypeId = tileTypeId ?? appState.selectedTileType?.id;
final offlineService = OfflineAreaService();
await offlineService.ensureInitialized();
@@ -20,7 +30,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
if (z < area.minZoom || z > area.maxZoom) continue;
// Only consider areas that match the current provider/type
if (area.tileProviderId != currentProvider?.id || area.tileTypeId != currentTileType?.id) continue;
if (area.tileProviderId != currentProviderId || area.tileTypeId != currentTileTypeId) continue;
// Get tile coverage for area at this zoom only
final coveredTiles = computeTileList(area.bounds, z, z);
@@ -35,7 +45,7 @@ Future<List<int>> fetchLocalTile({required int z, required int x, required int y
}
}
if (candidates.isEmpty) {
throw Exception('Tile $z/$x/$y from current provider ${currentProvider?.id}/${currentTileType?.id} not found in any offline area');
throw Exception('Tile $z/$x/$y from provider $currentProviderId/$currentTileTypeId not found in any offline area');
}
candidates.sort((a, b) => b.modified.compareTo(a.modified)); // newest first
return await candidates.first.file.readAsBytes();
@@ -1,265 +0,0 @@
import 'dart:math';
import 'dart:io';
import 'dart:async';
import 'package:flutter/foundation.dart';
import 'package:latlong2/latlong.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:deflockapp/dev_config.dart';
import 'package:deflockapp/services/http_client.dart';
/// Global semaphore to limit simultaneous tile fetches
final _tileFetchSemaphore = _SimpleSemaphore(kTileFetchConcurrentThreads);
/// Clear queued tile requests when map view changes significantly
void clearRemoteTileQueue() {
final clearedCount = _tileFetchSemaphore.clearQueue();
// Only log if we actually cleared something significant
if (clearedCount > 5) {
debugPrint('[RemoteTiles] Cleared $clearedCount queued tile requests');
}
}
/// Clear only tile requests that are no longer visible in the given bounds
void clearRemoteTileQueueSelective(LatLngBounds currentBounds) {
final clearedCount = _tileFetchSemaphore.clearStaleRequests((z, x, y) {
// Return true if tile should be cleared (i.e., is NOT visible)
return !_isTileVisible(z, x, y, currentBounds);
});
if (clearedCount > 0) {
debugPrint('[RemoteTiles] Selectively cleared $clearedCount non-visible tile requests');
}
}
/// Calculate retry delay using configurable backoff strategy.
/// Uses: initialDelay * (multiplier ^ (attempt - 1)) + randomJitter, capped at maxDelay
int _calculateRetryDelay(int attempt, Random random) {
// Calculate exponential backoff
final baseDelay = (kTileFetchInitialDelayMs *
pow(kTileFetchBackoffMultiplier, attempt - 1)).round();
// Add random jitter to avoid thundering herd
final jitter = random.nextInt(kTileFetchRandomJitterMs + 1);
// Apply max delay cap
return (baseDelay + jitter).clamp(0, kTileFetchMaxDelayMs);
}
/// Convert tile coordinates to lat/lng bounds for spatial filtering
class _TileBounds {
final double north, south, east, west;
_TileBounds({required this.north, required this.south, required this.east, required this.west});
}
/// Calculate the lat/lng bounds for a given tile
_TileBounds _tileToBounds(int z, int x, int y) {
final n = pow(2, z);
final lon1 = (x / n) * 360.0 - 180.0;
final lon2 = ((x + 1) / n) * 360.0 - 180.0;
final lat1 = _yToLatitude(y, z);
final lat2 = _yToLatitude(y + 1, z);
return _TileBounds(
north: max(lat1, lat2),
south: min(lat1, lat2),
east: max(lon1, lon2),
west: min(lon1, lon2),
);
}
/// Convert tile Y coordinate to latitude
double _yToLatitude(int y, int z) {
final n = pow(2, z);
final latRad = atan(_sinh(pi * (1 - 2 * y / n)));
return latRad * 180.0 / pi;
}
/// Hyperbolic sine function: sinh(x) = (e^x - e^(-x)) / 2
double _sinh(double x) {
return (exp(x) - exp(-x)) / 2;
}
/// Check if a tile intersects with the current view bounds
bool _isTileVisible(int z, int x, int y, LatLngBounds viewBounds) {
final tileBounds = _tileToBounds(z, x, y);
// Check if tile bounds intersect with view bounds
return !(tileBounds.east < viewBounds.west ||
tileBounds.west > viewBounds.east ||
tileBounds.north < viewBounds.south ||
tileBounds.south > viewBounds.north);
}
final _tileClient = UserAgentClient();
/// Fetches a tile from any remote provider with unlimited retries.
/// Returns tile image bytes. Retries forever until success.
/// Brutalist approach: Keep trying until it works - no arbitrary retry limits.
Future<List<int>> fetchRemoteTile({
required int z,
required int x,
required int y,
required String url,
}) async {
int attempt = 0;
final random = Random();
final hostInfo = Uri.parse(url).host; // For logging
while (true) {
await _tileFetchSemaphore.acquire(z: z, x: x, y: y);
try {
// Only log on first attempt
if (attempt == 0) {
debugPrint('[fetchRemoteTile] Fetching $z/$x/$y from $hostInfo');
}
attempt++;
final resp = await _tileClient.get(Uri.parse(url));
if (resp.statusCode == 200 && resp.bodyBytes.isNotEmpty) {
// Success!
if (attempt > 1) {
debugPrint('[fetchRemoteTile] SUCCESS $z/$x/$y from $hostInfo after $attempt attempts');
}
return resp.bodyBytes;
} else {
debugPrint('[fetchRemoteTile] FAIL $z/$x/$y from $hostInfo: code=${resp.statusCode}, bytes=${resp.bodyBytes.length}');
throw HttpException('Failed to fetch tile $z/$x/$y from $hostInfo: status ${resp.statusCode}');
}
} catch (e) {
// Calculate delay and retry (no attempt limit - keep trying forever)
final delay = _calculateRetryDelay(attempt, random);
if (attempt == 1) {
debugPrint("[fetchRemoteTile] Attempt $attempt for $z/$x/$y from $hostInfo failed: $e. Retrying in ${delay}ms.");
} else if (attempt % 10 == 0) {
// Log every 10th attempt to show we're still working
debugPrint("[fetchRemoteTile] Still trying $z/$x/$y from $hostInfo (attempt $attempt). Retrying in ${delay}ms.");
}
await Future.delayed(Duration(milliseconds: delay));
} finally {
_tileFetchSemaphore.release(z: z, x: x, y: y);
}
}
}
/// Legacy function for backward compatibility
@Deprecated('Use fetchRemoteTile instead')
Future<List<int>> fetchOSMTile({
required int z,
required int x,
required int y,
}) async {
return fetchRemoteTile(
z: z,
x: x,
y: y,
url: 'https://tile.openstreetmap.org/$z/$x/$y.png',
);
}
/// Enhanced tile request entry that tracks coordinates for spatial filtering
class _TileRequest {
final int z, x, y;
final VoidCallback callback;
_TileRequest({required this.z, required this.x, required this.y, required this.callback});
}
/// Spatially-aware counting semaphore for tile requests with deduplication
class _SimpleSemaphore {
final int _max;
int _current = 0;
final List<_TileRequest> _queue = [];
final Set<String> _inFlightTiles = {}; // Track in-flight requests for deduplication
_SimpleSemaphore(this._max);
Future<void> acquire({int? z, int? x, int? y}) async {
// Create tile key for deduplication
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
// If this tile is already in flight, skip the request
if (_inFlightTiles.contains(tileKey)) {
debugPrint('[SimpleSemaphore] Skipping duplicate request for $tileKey');
return;
}
// Add to in-flight tracking
_inFlightTiles.add(tileKey);
if (_current < _max) {
_current++;
return;
} else {
// Check queue size limit to prevent memory bloat
if (_queue.length >= kTileFetchMaxQueueSize) {
// Remove oldest request to make room
final oldRequest = _queue.removeAt(0);
final oldKey = '${oldRequest.z}/${oldRequest.x}/${oldRequest.y}';
_inFlightTiles.remove(oldKey);
debugPrint('[SimpleSemaphore] Queue full, dropped oldest request: $oldKey');
}
final c = Completer<void>();
final request = _TileRequest(
z: z ?? -1,
x: x ?? -1,
y: y ?? -1,
callback: () => c.complete(),
);
_queue.add(request);
await c.future;
}
}
void release({int? z, int? x, int? y}) {
// Remove from in-flight tracking
final tileKey = '${z ?? -1}/${x ?? -1}/${y ?? -1}';
_inFlightTiles.remove(tileKey);
if (_queue.isNotEmpty) {
final request = _queue.removeAt(0);
request.callback();
} else {
_current--;
}
}
/// Clear all queued requests (call when view changes significantly)
int clearQueue() {
final clearedCount = _queue.length;
_queue.clear();
_inFlightTiles.clear(); // Also clear deduplication tracking
return clearedCount;
}
/// Clear only tiles that don't pass the visibility filter
int clearStaleRequests(bool Function(int z, int x, int y) isStale) {
final initialCount = _queue.length;
final initialInFlightCount = _inFlightTiles.length;
// Remove stale requests from queue
_queue.removeWhere((request) => isStale(request.z, request.x, request.y));
// Remove stale tiles from in-flight tracking
_inFlightTiles.removeWhere((tileKey) {
final parts = tileKey.split('/');
if (parts.length == 3) {
final z = int.tryParse(parts[0]) ?? -1;
final x = int.tryParse(parts[1]) ?? -1;
final y = int.tryParse(parts[2]) ?? -1;
return isStale(z, x, y);
}
return false;
});
final queueClearedCount = initialCount - _queue.length;
final inFlightClearedCount = initialInFlightCount - _inFlightTiles.length;
if (queueClearedCount > 0 || inFlightClearedCount > 0) {
debugPrint('[SimpleSemaphore] Cleared $queueClearedCount stale queue + $inFlightClearedCount stale in-flight, kept ${_queue.length}');
}
return queueClearedCount + inFlightClearedCount;
}
}
+16 -15
View File
@@ -22,7 +22,7 @@ class TileLayerManager {
/// Dispose of resources
void dispose() {
// No resources to dispose with the new tile provider
_tileProvider?.dispose();
}
/// Check if cache should be cleared and increment rebuild key if needed.
@@ -45,7 +45,8 @@ class TileLayerManager {
if (shouldClear) {
// Force map rebuild with new key to bust flutter_map cache
_mapRebuildKey++;
// Also force new tile provider instance to ensure fresh cache
// Dispose old provider before creating a fresh one (closes HTTP client)
_tileProvider?.dispose();
_tileProvider = null;
debugPrint('[TileLayerManager] *** CACHE CLEAR *** $reason changed - rebuilding map $_mapRebuildKey');
}
@@ -58,7 +59,7 @@ class TileLayerManager {
/// Clear the tile request queue (call after cache clear)
void clearTileQueue() {
// With the new tile provider, clearing is handled by FlutterMap's internal cache
// 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');
@@ -66,14 +67,12 @@ class TileLayerManager {
/// Clear tile queue immediately (for zoom changes, etc.)
void clearTileQueueImmediate() {
// No immediate clearing needed with the new architecture
// FlutterMap handles this naturally
// No immediate clearing needed — NetworkTileProvider aborts obsolete requests
}
/// Clear only tiles that are no longer visible in the current bounds
void clearStaleRequests({required LatLngBounds currentBounds}) {
// No selective clearing needed with the new architecture
// FlutterMap's internal caching is efficient enough
// No selective clearing needed — NetworkTileProvider aborts obsolete requests
}
/// Build tile layer widget with current provider and type.
@@ -84,16 +83,18 @@ class TileLayerManager {
}) {
// Create a fresh tile provider instance if we don't have one or cache was cleared
_tileProvider ??= DeflockTileProvider();
// Use provider/type info in URL template for FlutterMap's cache key generation
// This ensures different providers/types get different cache keys
final urlTemplate = '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
// Use the actual urlTemplate from the selected tile type. Our getTileUrl()
// override handles the real URL generation; flutter_map uses urlTemplate
// internally for cache key generation.
final urlTemplate = selectedTileType?.urlTemplate
?? '${selectedProvider?.id ?? 'unknown'}/${selectedTileType?.id ?? 'unknown'}/{z}/{x}/{y}';
return TileLayer(
urlTemplate: urlTemplate, // Critical for cache key generation
urlTemplate: urlTemplate,
userAgentPackageName: 'me.deflock.deflockapp',
maxZoom: selectedTileType?.maxZoom.toDouble() ?? 18.0,
tileProvider: _tileProvider!,
);
}
}
}
+206 -74
View File
@@ -1,119 +1,251 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_map/flutter_map.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
import 'package:deflockapp/app_state.dart';
import 'package:deflockapp/models/tile_provider.dart' as models;
import 'package:deflockapp/services/deflock_tile_provider.dart';
import 'package:deflockapp/services/map_data_provider.dart';
class MockAppState extends Mock implements AppState {}
void main() {
late DeflockTileProvider provider;
late MockAppState mockAppState;
setUp(() {
mockAppState = MockAppState();
AppState.instance = mockAppState;
// Default stubs: online, OSM provider selected, no offline areas
when(() => mockAppState.offlineMode).thenReturn(false);
when(() => mockAppState.selectedTileProvider).thenReturn(
const models.TileProvider(
id: 'openstreetmap',
name: 'OpenStreetMap',
tileTypes: [],
),
);
when(() => mockAppState.selectedTileType).thenReturn(
const models.TileType(
id: 'osm_street',
name: 'Street Map',
urlTemplate: 'https://tile.openstreetmap.org/{z}/{x}/{y}.png',
attribution: '© OpenStreetMap',
maxZoom: 19,
),
);
provider = DeflockTileProvider();
});
tearDown(() async {
await provider.dispose();
AppState.instance = MockAppState();
});
group('DeflockTileProvider', () {
late DeflockTileProvider provider;
late MockAppState mockAppState;
setUp(() {
provider = DeflockTileProvider();
mockAppState = MockAppState();
when(() => mockAppState.selectedTileProvider).thenReturn(null);
when(() => mockAppState.selectedTileType).thenReturn(null);
AppState.instance = mockAppState;
test('supportsCancelLoading is true', () {
expect(provider.supportsCancelLoading, isTrue);
});
tearDown(() {
// Reset to a clean mock so stubbed state doesn't leak to other tests
AppState.instance = MockAppState();
test('getTileUrl() delegates to TileType.getTileUrl()', () {
const coords = TileCoordinates(1, 2, 3);
final options = TileLayer(urlTemplate: 'ignored/{z}/{x}/{y}');
final url = provider.getTileUrl(coords, options);
expect(url, equals('https://tile.openstreetmap.org/3/1/2.png'));
});
test('creates image provider for tile coordinates', () {
const coordinates = TileCoordinates(0, 0, 0);
final options = TileLayer(
urlTemplate: 'test/{z}/{x}/{y}',
test('getTileUrl() includes API key when present', () {
when(() => mockAppState.selectedTileProvider).thenReturn(
const models.TileProvider(
id: 'mapbox',
name: 'Mapbox',
apiKey: 'test_key_123',
tileTypes: [],
),
);
when(() => mockAppState.selectedTileType).thenReturn(
const models.TileType(
id: 'mapbox_satellite',
name: 'Satellite',
urlTemplate:
'https://api.mapbox.com/v4/mapbox.satellite/{z}/{x}/{y}@2x.jpg90?access_token={api_key}',
attribution: '© Mapbox',
),
);
final imageProvider = provider.getImage(coordinates, options);
const coords = TileCoordinates(1, 2, 10);
final options = TileLayer(urlTemplate: 'ignored');
expect(imageProvider, isA<DeflockTileImageProvider>());
expect((imageProvider as DeflockTileImageProvider).coordinates,
equals(coordinates));
final url = provider.getTileUrl(coords, options);
expect(url, contains('access_token=test_key_123'));
expect(url, contains('/10/1/2@2x'));
});
test('getTileUrl() falls back to super when no provider selected', () {
when(() => mockAppState.selectedTileProvider).thenReturn(null);
when(() => mockAppState.selectedTileType).thenReturn(null);
const coords = TileCoordinates(1, 2, 3);
final options = TileLayer(urlTemplate: 'https://example.com/{z}/{x}/{y}');
final url = provider.getTileUrl(coords, options);
// Super implementation uses the urlTemplate from TileLayer options
expect(url, equals('https://example.com/3/1/2'));
});
test('routes to network path when no offline areas exist', () {
// offlineMode = false, OfflineAreaService not initialized → no offline areas
const coords = TileCoordinates(5, 10, 12);
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final cancelLoading = Future<void>.value();
final imageProvider = provider.getImageWithCancelLoadingSupport(
coords,
options,
cancelLoading,
);
// Should NOT be a DeflockOfflineTileImageProvider — it should be the
// NetworkTileImageProvider returned by super
expect(imageProvider, isNot(isA<DeflockOfflineTileImageProvider>()));
});
test('routes to offline path when offline mode is enabled', () {
when(() => mockAppState.offlineMode).thenReturn(true);
const coords = TileCoordinates(5, 10, 12);
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final cancelLoading = Future<void>.value();
final imageProvider = provider.getImageWithCancelLoadingSupport(
coords,
options,
cancelLoading,
);
expect(imageProvider, isA<DeflockOfflineTileImageProvider>());
final offlineProvider = imageProvider as DeflockOfflineTileImageProvider;
expect(offlineProvider.isOfflineOnly, isTrue);
expect(offlineProvider.coordinates, equals(coords));
expect(offlineProvider.providerId, equals('openstreetmap'));
expect(offlineProvider.tileTypeId, equals('osm_street'));
});
});
group('DeflockTileImageProvider', () {
test('generates consistent keys for same coordinates', () {
const coordinates1 = TileCoordinates(1, 2, 3);
const coordinates2 = TileCoordinates(1, 2, 3);
const coordinates3 = TileCoordinates(1, 2, 4);
group('DeflockOfflineTileImageProvider', () {
test('equal for same coordinates and provider/type', () {
const coords = TileCoordinates(1, 2, 3);
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final cancel = Future<void>.value();
final mapDataProvider = MapDataProvider();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates1,
final a = DeflockOfflineTileImageProvider(
coordinates: coords,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_a',
tileTypeId: 'type_1',
tileUrl: 'https://example.com/3/1/2',
);
final provider2 = DeflockTileImageProvider(
coordinates: coordinates2,
final b = DeflockOfflineTileImageProvider(
coordinates: coords,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
);
final provider3 = DeflockTileImageProvider(
coordinates: coordinates3,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'test_provider',
tileTypeId: 'test_type',
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: true, // different — but not in ==
providerId: 'prov_a',
tileTypeId: 'type_1',
tileUrl: 'https://other.com/3/1/2', // different — but not in ==
);
// Same coordinates should be equal
expect(provider1, equals(provider2));
expect(provider1.hashCode, equals(provider2.hashCode));
// Different coordinates should not be equal
expect(provider1, isNot(equals(provider3)));
expect(a, equals(b));
expect(a.hashCode, equals(b.hashCode));
});
test('generates different keys for different providers/types', () {
const coordinates = TileCoordinates(1, 2, 3);
test('not equal for different coordinates', () {
const coords1 = TileCoordinates(1, 2, 3);
const coords2 = TileCoordinates(1, 2, 4);
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final mapDataProvider = MapDataProvider();
final cancel = Future<void>.value();
final provider1 = DeflockTileImageProvider(
coordinates: coordinates,
final a = DeflockOfflineTileImageProvider(
coordinates: coords1,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_a',
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_a',
tileTypeId: 'type_1',
tileUrl: 'url1',
);
final provider2 = DeflockTileImageProvider(
coordinates: coordinates,
final b = DeflockOfflineTileImageProvider(
coordinates: coords2,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_b',
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_a',
tileTypeId: 'type_1',
tileUrl: 'url2',
);
final provider3 = DeflockTileImageProvider(
coordinates: coordinates,
expect(a, isNot(equals(b)));
});
test('not equal for different provider or type', () {
const coords = TileCoordinates(1, 2, 3);
final options = TileLayer(urlTemplate: 'test/{z}/{x}/{y}');
final cancel = Future<void>.value();
final base = DeflockOfflineTileImageProvider(
coordinates: coords,
options: options,
mapDataProvider: mapDataProvider,
providerId: 'provider_a',
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_a',
tileTypeId: 'type_1',
tileUrl: 'url',
);
final diffProvider = DeflockOfflineTileImageProvider(
coordinates: coords,
options: options,
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_b',
tileTypeId: 'type_1',
tileUrl: 'url',
);
final diffType = DeflockOfflineTileImageProvider(
coordinates: coords,
options: options,
httpClient: http.Client(),
headers: const {},
cancelLoading: cancel,
isOfflineOnly: false,
providerId: 'prov_a',
tileTypeId: 'type_2',
tileUrl: 'url',
);
// Different providers should not be equal (even with same coordinates)
expect(provider1, isNot(equals(provider2)));
expect(provider1.hashCode, isNot(equals(provider2.hashCode)));
// Different tile types should not be equal (even with same coordinates and provider)
expect(provider1, isNot(equals(provider3)));
expect(provider1.hashCode, isNot(equals(provider3.hashCode)));
expect(base, isNot(equals(diffProvider)));
expect(base.hashCode, isNot(equals(diffProvider.hashCode)));
expect(base, isNot(equals(diffType)));
expect(base.hashCode, isNot(equals(diffType.hashCode)));
});
});
}