Files
deflock-app/lib/services/deflock_tile_provider.dart
Doug Borg 8983939b05 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>
2026-02-24 20:07:56 -07:00

272 lines
8.8 KiB
Dart

import 'dart:async';
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 'http_client.dart';
import 'map_data_submodules/tiles_from_local.dart';
import 'offline_area_service.dart';
/// 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
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 DeflockOfflineTileImageProvider(
coordinates: coordinates,
options: options,
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 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 Client httpClient;
final Map<String, String> headers;
final Future<void> cancelLoading;
final bool isOfflineOnly;
final String providerId;
final String tileTypeId;
final String tileUrl;
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,
});
@override
Future<DeflockOfflineTileImageProvider> obtainKey(
ImageConfiguration configuration) {
return SynchronousFuture<DeflockOfflineTileImageProvider>(this);
}
@override
ImageStreamCompleter loadImage(
DeflockOfflineTileImageProvider key, ImageDecoderCallback decode) {
final chunkEvents = StreamController<ImageChunkEvent>();
final codecFuture = _loadAsync(key, decode, chunkEvents);
codecFuture.whenComplete(() {
chunkEvents.close();
});
return MultiFrameImageStreamCompleter(
codec: codecFuture,
chunkEvents: chunkEvents.stream,
scale: 1.0,
);
}
Future<Codec> _loadAsync(
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 {
// 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
}
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
if (!e.toString().contains('offline')) {
debugPrint(
'[DeflockTileProvider] Offline-first tile failed '
'${coordinates.z}/${coordinates.x}/${coordinates.y} '
'(${e.runtimeType})');
}
return await ImmutableBuffer.fromUint8List(TileProvider.transparentImage)
.then(decode);
}
}
@override
bool operator ==(Object other) {
if (other.runtimeType != runtimeType) return false;
return other is DeflockOfflineTileImageProvider &&
other.coordinates == coordinates &&
other.providerId == providerId &&
other.tileTypeId == tileTypeId;
}
@override
int get hashCode => Object.hash(coordinates, providerId, tileTypeId);
}