Merge pull request #127 from dougborg/hybrid-tile-provider

Delegate network tile fetching to NetworkTileProvider
This commit is contained in:
stopflock
2026-02-24 21:31:11 -06:00
committed by GitHub
7 changed files with 465 additions and 486 deletions

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;

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;
}
}
}

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)

View File

@@ -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();

View File

@@ -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;
}
}

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!,
);
}
}
}

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)));
});
});
}