cache management

This commit is contained in:
randogoth 2025-03-04 20:50:16 +02:00
parent ab4accfc8c
commit fa7e5b1c62
2 changed files with 268 additions and 108 deletions

View file

@ -14,6 +14,7 @@ class RugReefer {
final Duration cacheExpirationDuration;
final http.Client httpClient;
final String categoryMap;
final int cacheSizeThreshold; // Cache size threshold in bytes (default: 20MB)
Directory? cacheDir;
RugReefer({
@ -21,6 +22,7 @@ class RugReefer {
required this.categoryMap,
this.enableCacheExpiration = false,
this.cacheExpirationDuration = const Duration(days: 14),
this.cacheSizeThreshold = 20 * 1024 * 1024, // 20MB in bytes
http.Client? client,
}) : httpClient = client ?? http.Client() {
_initCacheDir();
@ -29,87 +31,107 @@ class RugReefer {
Future<void> _initCacheDir() async {
if (!kIsWeb) {
cacheDir = await getApplicationDocumentsDirectory();
if (enableCacheExpiration) {
await _cleanExpiredCache();
}
if (enableCacheExpiration) await _cleanExpiredCache();
}
}
Future<Map<String, Map<String, List<String>>>> lookup(double latitude, double longitude, {double distanceThreshold = 10.0}) async {
final String geohash = GeoHasher().encode(longitude, latitude, precision: 4);
print("Generated geohash: $geohash");
final geohash = GeoHasher().encode(longitude, latitude, precision: 4);
final jsonMap = await loadJsonFromAssets(categoryMap);
print("Loaded JSON Map from Assets");
try {
List<Map<String, dynamic>> rawData = kIsWeb
? await _lookupWeb(geohash, latitude, longitude, distanceThreshold)
: await _lookupFile(geohash, latitude, longitude, distanceThreshold);
print("Raw Data from lookup: ${jsonEncode(rawData)}");
final processedData = _processMetadata(rawData, jsonMap, latitude, longitude, distanceThreshold);
print("Processed Data: ${jsonEncode(processedData)}");
return processedData;
final rawData = kIsWeb
? await _lookupWeb(geohash)
: await _lookupFile(geohash);
return _processMetadata(rawData, jsonMap, Position(longitude, latitude), distanceThreshold);
} catch (e, stackTrace) {
print("Error in lookup: $e");
print("StackTrace: $stackTrace");
print("Error in lookup: $e\nStackTrace: $stackTrace");
return {};
}
}
Future<List<Map<String, dynamic>>> _lookupFile(String geohash, double latitude, double longitude, double distanceThreshold) async {
Future<List<Map<String, dynamic>>> _lookupFile(String geohash) async {
await _initCacheDir();
final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
print("Checking cache file: ${cacheFile.path}");
final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
if (!cacheFile.existsSync() || await _isCacheExpired(geohash)) {
print("Cache file missing or expired, downloading...");
await _downloadGeoJson(geohash, cacheFile);
} else {
print("Cache file found, updating last access time");
await _updateLastAccessTime(geohash);
}
final String geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync()));
print("Read from cache: ${geoJsonStr.length} characters");
// Explicitly cast to List<Map<String, dynamic>>
final List<dynamic> decodedJson = jsonDecode(geoJsonStr)['features'];
return decodedJson.cast<Map<String, dynamic>>();
final geoJsonStr = utf8.decode(GZipCodec().decode(cacheFile.readAsBytesSync()));
return (jsonDecode(geoJsonStr)['features'] as List).cast<Map<String, dynamic>>();
}
Future<List<Map<String, dynamic>>> _lookupWeb(String geohash, double latitude, double longitude, double distanceThreshold) async {
Future<List<Map<String, dynamic>>> _lookupWeb(String geohash) async {
final prefs = await SharedPreferences.getInstance();
final String? cachedGeoJson = prefs.getString('geojson_$geohash');
final cachedGeoJson = prefs.getString('geojson_$geohash');
if (cachedGeoJson == null || await _isCacheExpired(geohash)) {
final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson'));
if (response.statusCode == 200) {
final geoJsonStr = utf8.decode(response.bodyBytes);
await prefs.setString('geojson_$geohash', geoJsonStr);
await _updateLastAccessTime(geohash);
return jsonDecode(geoJsonStr)['features'];
return (jsonDecode(geoJsonStr)['features'] as List).cast<Map<String, dynamic>>();
}
throw Exception('Failed to download GeoJSON for geohash: $geohash');
}
await _updateLastAccessTime(geohash);
return jsonDecode(cachedGeoJson)['features'];
return (jsonDecode(cachedGeoJson)['features'] as List).cast<Map<String, dynamic>>();
}
Future<void> _downloadGeoJson(String geohash, File cacheFile) async {
final response = await httpClient.get(Uri.parse('$apiUrl?geohash=$geohash&format=geojson'));
if (response.statusCode == 200) {
cacheFile.writeAsBytesSync(GZipCodec().encode(response.bodyBytes));
final bytes = response.bodyBytes;
cacheFile.writeAsBytesSync(GZipCodec().encode(bytes));
await _updateLastAccessTime(geohash);
await _ensureCacheSizeWithinThreshold(bytes.length); // Check and manage cache size
} else {
throw Exception('Failed to download GeoJSON for geohash: $geohash');
}
}
Future<void> _ensureCacheSizeWithinThreshold(int newFileSize) async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size)));
int totalSize = fileSizes.fold(0, (sum, size) => sum + size);
print("Current cache size: ${totalSize / 1024 / 1024} MB");
if (totalSize + newFileSize <= cacheSizeThreshold) return; // Within threshold
// Sort files by last access time (oldest first)
final prefs = await SharedPreferences.getInstance();
final sortedFiles = await Future.wait(files.map((file) async {
final geohash = file.path.split('/').last.replaceAll('.geojson.gz', '');
final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0;
return {'file': file, 'lastAccess': lastAccess};
}));
sortedFiles.sort((a, b) => a['lastAccess'].compareTo(b['lastAccess']));
// Delete oldest files until we're within the threshold
for (var entry in sortedFiles) {
final file = entry['file'] as File;
final size = await file.length();
file.deleteSync();
totalSize -= size;
print("Deleted file: ${file.path} (${size / 1024 / 1024} MB)");
if (totalSize + newFileSize <= cacheSizeThreshold) break;
}
print("New cache size: ${totalSize / 1024 / 1024} MB");
}
Future<void> _updateLastAccessTime(String geohash) async {
final prefs = await SharedPreferences.getInstance();
await prefs.setInt('cache_last_access_$geohash', DateTime.now().millisecondsSinceEpoch);
@ -118,7 +140,7 @@ class RugReefer {
Future<bool> _isCacheExpired(String geohash) async {
if (!enableCacheExpiration) return false;
final prefs = await SharedPreferences.getInstance();
final int? lastAccess = prefs.getInt('cache_last_access_$geohash');
final lastAccess = prefs.getInt('cache_last_access_$geohash');
return lastAccess == null || DateTime.now().difference(DateTime.fromMillisecondsSinceEpoch(lastAccess)) > cacheExpirationDuration;
}
@ -128,76 +150,110 @@ class RugReefer {
for (var key in keys) {
final geohash = key.replaceFirst('cache_last_access_', '');
if (await _isCacheExpired(geohash)) {
final File cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
if (cacheFile.existsSync()) cacheFile.deleteSync();
await prefs.remove(key);
}
}
}
Future<void> clearCache() async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
// Delete all cached files
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
for (var file in files) {
if (file is File) {
file.deleteSync();
print("Deleted file: ${file.path}");
}
}
// Remove all last access times from SharedPreferences
final keys = prefs.getKeys().where((key) => key.startsWith('cache_last_access_')).toList();
for (var key in keys) {
await prefs.remove(key);
print("Removed cache access time for key: $key");
}
print("Cache cleared successfully.");
}
/// Returns the total size of the cache in bytes.
Future<int> getCurrentCacheSize() async {
if (kIsWeb || cacheDir == null) return 0; // Skip for web or if cacheDir is not initialized
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileSizes = await Future.wait(files.map((file) => file.stat().then((stat) => stat.size)));
return fileSizes.fold(0, (sum, size) => sum + size);
}
/// Returns a list of currently cached files with their sizes and last access times.
Future<List<Map<String, dynamic>>> listCachedFiles() async {
if (kIsWeb || cacheDir == null) return []; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
final files = await cacheDir!.list().where((file) => file.path.endsWith('.geojson.gz')).toList();
final fileDetails = await Future.wait(files.map((file) async {
final geohash = file.path.split('/').last.replaceAll('.geojson.gz', '');
final lastAccess = prefs.getInt('cache_last_access_$geohash') ?? 0;
final size = await file.stat().then((stat) => stat.size);
return {
'file': file.path,
'size': size,
'lastAccess': DateTime.fromMillisecondsSinceEpoch(lastAccess),
};
}));
return fileDetails;
}
/// Deletes a specific cached file by its geohash.
Future<void> deleteCachedFile(String geohash) async {
if (kIsWeb || cacheDir == null) return; // Skip for web or if cacheDir is not initialized
final prefs = await SharedPreferences.getInstance();
final cacheFile = File('${cacheDir?.path}/$geohash.geojson.gz');
if (cacheFile.existsSync()) {
cacheFile.deleteSync();
print("Deleted file: ${cacheFile.path}");
}
// Remove the last access time for this geohash
await prefs.remove('cache_last_access_$geohash');
print("Removed cache access time for geohash: $geohash");
}
Future<Map<String, dynamic>> loadJsonFromAssets(String assetPath) async {
final jsonString = await rootBundle.loadString(assetPath);
return jsonDecode(jsonString);
return jsonDecode(await rootBundle.loadString(assetPath));
}
Map<String, Map<String, List<String>>> _processMetadata(
List<Map<String, dynamic>> features,
Map<String, dynamic> jsonMap,
double latitude,
double longitude,
Position point,
double distanceThreshold) {
final Position point = Position(longitude, latitude);
Map<String, Map<String, List<String>>> groupedObjects = {"in": {}, "by": {}};
final groupedObjects = {"in": <String, List<String>>{}, "by": <String, List<String>>{}};
for (var feature in features) {
final geometry = feature['geometry'];
final metadata = Map<String, dynamic>.from(feature['properties']['tags'] ?? {})..removeWhere((_, v) => v == null);
String? mainKey;
// Determine spatial category: "in" (Polygon) or "by" (LineString)
switch (geometry['type']) {
case 'Polygon':
final polygon = Polygon(
coordinates: (geometry['coordinates'] as List)
.map((ring) => (ring as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList())
.toList(),
);
if (booleanPointInPolygon(point, polygon)) mainKey = 'in';
break;
case 'LineString':
final line = LineString(
coordinates: (geometry['coordinates'] as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList(),
);
if (_isPointNearLine(Point(coordinates: point), line, distanceThreshold)) mainKey = 'by';
break;
}
final mainKey = _determineSpatialCategory(geometry, point, distanceThreshold);
if (mainKey == null) continue;
for (var key in metadata.keys) {
var value = metadata[key];
final value = metadata[key];
for (var category in jsonMap.keys) {
var categoryMap = jsonMap[category];
if (categoryMap is Map && categoryMap.containsKey(key)) {
var rule = categoryMap[key];
if (rule is String && rule == value) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
} else if (rule is List && rule.contains(value)) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
} else if (rule == true) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []);
groupedObjects[mainKey]![category]!.add(value);
}
final rule = jsonMap[category] is Map ? jsonMap[category][key] : null;
if (_matchesRule(rule, value)) {
groupedObjects[mainKey]!.putIfAbsent(category, () => []).add(value);
}
}
}
@ -206,15 +262,43 @@ class RugReefer {
return groupedObjects;
}
bool _isPointNearLine(Point point, LineString line, double distanceThreshold) {
for (final Position p in line.coordinates) {
if (distance(point, Point(coordinates: p), Unit.meters) <= distanceThreshold) {
return true;
}
String? _determineSpatialCategory(Map<String, dynamic> geometry, Position point, double distanceThreshold) {
switch (geometry['type']) {
case 'Polygon':
final polygon = Polygon(
coordinates: (geometry['coordinates'] as List)
.map((ring) => (ring as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList())
.toList(),
);
return booleanPointInPolygon(point, polygon) ? 'in' : null;
case 'LineString':
final line = LineString(
coordinates: (geometry['coordinates'] as List)
.map((p) => Position(p[0].toDouble(), p[1].toDouble()))
.toList(),
);
return _isPointNearLine(Point(coordinates: point), line, distanceThreshold) ? 'by' : null;
default:
return null;
}
}
bool _matchesRule(dynamic rule, dynamic value) {
if (rule == true) return true;
if (rule is String && rule == value) return true;
if (rule is List && rule.contains(value)) return true;
return false;
}
}
bool _isPointNearLine(Point point, LineString line, double distanceThreshold) {
for (int i = 0; i < line.coordinates.length - 1; i++) {
final p1 = line.coordinates[i];
final p2 = line.coordinates[i + 1];
final dist = distance(p1, point, Unit.meter);
if (dist <= distanceThreshold) return true;
}
return false;
}
}