Merge FB-11: fix Route Planner tile rendering (tile cache concurrency + logging)

This commit is contained in:
2026-08-25 11:37:00 -05:00
4 changed files with 168 additions and 23 deletions

View File

@@ -91,7 +91,11 @@ class _CacheBackedImage extends ImageProvider<_CacheBackedImage> {
await cache.put(key, bytes);
connectivity?.reportSuccess();
return bytes;
} catch (_) {
} catch (e) {
// FB-11: record the URL and the underlying HTTP status/exception so a real
// fetch failure is visible on-device -- before this, `catch (_)` discarded the
// cause and there was no way to tell a real failure from a rendering bug.
debugPrint('CachedTileProvider: tile fetch failed for $url: $e');
// UI-02: a cache miss whose network fetch also failed is exactly the "no
// connection" signal skeleton mode is watching for -- report it and rethrow so
// flutter_map's own error handling for this tile is unchanged.

View File

@@ -30,16 +30,44 @@ abstract class TileCache {
/// last-access time for LRU eviction. No database engine for what is, at the end of the
/// day, a directory of small binary blobs with one number (last access) attached to each.
class FileTileCache implements TileCache {
FileTileCache({required Directory directory, required this.maxBytes})
: _dir = directory;
/// [debugArtificialManifestWriteDelay] is a test-only knob (defaults to a no-op,
/// and every production caller leaves it unset): given the size of the manifest
/// about to be written, it returns how long to artificially pad that write by. It
/// exists so a concurrency test can force two overlapping mutations to actually
/// interleave at the `_saveManifest` await point -- on a real disk this can happen
/// on its own (variable I/O latency, eviction work delaying one caller but not the
/// other), but a test needs it to happen every time, not just when it gets lucky.
FileTileCache({
required Directory directory,
required this.maxBytes,
Duration Function(int entryCount)? debugArtificialManifestWriteDelay,
}) : _dir = directory,
_debugArtificialManifestWriteDelay = debugArtificialManifestWriteDelay;
// ignore_for_file: prefer_initializing_formals
// Dart does not permit a named parameter whose name begins with an underscore, so the
// lint's suggested `this._debugArtificialManifestWriteDelay` will not compile here.
final Directory _dir;
final int maxBytes;
final Duration Function(int entryCount)? _debugArtificialManifestWriteDelay;
final _manifest = <String, _Entry>{};
bool _loaded = false;
int _clock = 0;
/// Serializes every mutating (and manifest-reading) operation so two overlapping
/// calls -- e.g. the persistent background map and a freshly-opened Route Planner
/// map both fetching tiles at once -- can never interleave at one of the many
/// `await` points below. Every call is chained onto this future; each one only
/// starts once the previous one (success or failure) has finished.
Future<void> _queue = Future.value();
Future<T> _serialized<T>(Future<T> Function() op) {
final result = _queue.then((_) => op());
_queue = result.then((_) {}, onError: (_) {});
return result;
}
File get _manifestFile => File('${_dir.path}/manifest.json');
File _tileFile(TileKey key) => File('${_dir.path}/${_fileName(key)}');
String _fileName(TileKey key) => '${key.z}_${key.x}_${key.y}.tile';
@@ -60,15 +88,20 @@ class FileTileCache implements TileCache {
}
}
Future<void> _saveManifest() => _manifestFile.writeAsString(
jsonEncode({
Future<void> _saveManifest() async {
final encoded = jsonEncode({
for (final e in _manifest.entries)
e.key: {'bytes': e.value.bytes, 'lastAccess': e.value.lastAccess},
}),
);
});
final delay = _debugArtificialManifestWriteDelay?.call(_manifest.length);
if (delay != null && delay > Duration.zero) {
await Future<void>.delayed(delay);
}
await _manifestFile.writeAsString(encoded);
}
@override
Future<void> put(TileKey key, Uint8List bytes) async {
Future<void> put(TileKey key, Uint8List bytes) => _serialized(() async {
await _ensureLoaded();
final name = _fileName(key);
@@ -83,7 +116,7 @@ class FileTileCache implements TileCache {
await _tileFile(key).writeAsBytes(bytes);
_manifest[name] = _Entry(bytes: bytes.length, lastAccess: _clock++);
await _saveManifest();
}
});
Future<void> _evictUntilFits(int incomingBytes) async {
// Oldest-accessed first.
@@ -100,7 +133,7 @@ class FileTileCache implements TileCache {
int _totalBytes() => _manifest.values.fold(0, (sum, e) => sum + e.bytes);
@override
Future<Uint8List?> get(TileKey key) async {
Future<Uint8List?> get(TileKey key) => _serialized(() async {
await _ensureLoaded();
final name = _fileName(key);
final entry = _manifest[name];
@@ -115,16 +148,16 @@ class FileTileCache implements TileCache {
}
entry.lastAccess = _clock++;
return file.readAsBytes();
}
});
@override
Future<int> sizeBytes() async {
Future<int> sizeBytes() => _serialized(() async {
await _ensureLoaded();
return _totalBytes();
}
});
@override
Future<void> clear() async {
Future<void> clear() => _serialized(() async {
await _ensureLoaded();
for (final key in _manifest.keys.toList()) {
final f = File('${_dir.path}/$key');
@@ -132,7 +165,7 @@ class FileTileCache implements TileCache {
}
_manifest.clear();
await _saveManifest();
}
});
@override
Future<void> dispose() async {}