import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; /// One cached response: the raw JSON payload and when it was stored. class CacheEntry { final String json; final int lastUpdate; const CacheEntry({required this.json, required this.lastUpdate}); } /// File-per-key store behind [RequestCache] and friends. /// /// Replaces `localstore` for response caching: localstore reads and decodes /// the entire collection synchronously on first access, keeps every document /// in memory for the whole session and JSON-encodes payloads a second time. /// Here every access is async and touches exactly one file, and the payload is /// stored verbatim behind a one-line header (`\n`). class CacheStore { CacheStore._(); static final CacheStore instance = CacheStore._(); /// Payloads above this size are decoded on a background isolate. static const int isolateDecodeThreshold = 64 * 1024; Future? _dir; final Map> _writes = {}; Future _directory() => _dir ??= () async { final base = await getApplicationCacheDirectory(); final dir = Directory('${base.path}/request_cache'); await dir.create(recursive: true); return dir; }(); Future _file(String key) async => File('${(await _directory()).path}/${_safeName(key)}'); /// Keys are internal ids (`nc-chat-`, `wd-folder-`); only path /// separators would be a problem in a file name. static String _safeName(String key) => key.replaceAll(RegExp(r'[/\\]'), '_'); Future read(String key) async { try { await _writes[key]; return parse(await (await _file(key)).readAsString()); } on Object { // Missing (PathNotFoundException) or unreadable: a cache miss. return null; } } /// All entries whose key starts with [prefix], read concurrently. Future> readAll({String prefix = ''}) async { final keys = await this.keys(prefix: prefix); final entries = await Future.wait(keys.map(read)); return {for (var i = 0; i < keys.length; i++) keys[i]: ?entries[i]}; } Future write(String key, String json) { final previous = _writes[key] ?? Future.value(); late final Future current; current = previous.then((_) => _write(key, json)).whenComplete(() { if (identical(_writes[key], current)) _writes.remove(key); }); return _writes[key] = current; } Future _write(String key, String json) async { try { final file = await _file(key); // Write-then-rename so a reader (or the widget background isolate) // never sees a half-written file. final tmp = File('${file.path}.tmp'); await tmp.writeAsString( '${DateTime.now().millisecondsSinceEpoch}\n$json', flush: true, ); await tmp.rename(file.path); } on Object catch (e) { debugPrint('CacheStore.write($key) failed: $e'); } } Future delete(String key) async { try { await _writes[key]; await (await _file(key)).delete(); } on Object { // A missing or locked file is as good as deleted for a cache. } } Future clear() async { try { final dir = await _directory(); if (dir.existsSync()) await dir.delete(recursive: true); } on Object catch (e) { debugPrint('CacheStore.clear failed: $e'); } _dir = null; } /// All stored keys, optionally filtered by [prefix]. Future> keys({String prefix = ''}) async { try { final dir = await _directory(); return [ await for (final entity in dir.list()) if (entity is File && !entity.path.endsWith('.tmp')) if (entity.uri.pathSegments.last case final name when name.startsWith(prefix)) name, ]; } on Object { return const []; } } /// Removes entries not written for [maxAge], judged by file mtime so nothing /// has to be read or decoded. Future deleteOlderThan(Duration maxAge) async { try { final dir = await _directory(); final cutoff = DateTime.now().subtract(maxAge); await for (final entity in dir.list()) { if (entity is! File) continue; // ignore: avoid_slow_async_io final modified = (await entity.stat()).modified; if (modified.isBefore(cutoff)) await entity.delete(); } } on Object catch (e) { debugPrint('CacheStore.deleteOlderThan failed: $e'); } } /// Total size of all entries in bytes. Future totalSize() async { try { final dir = await _directory(); var sum = 0; await for (final entity in dir.list()) { if (entity is File) sum += await entity.length(); } return sum; } on Object { return 0; } } /// Parses the stored file format; public for tests. static CacheEntry? parse(String raw) { final newline = raw.indexOf('\n'); if (newline < 0) return null; final lastUpdate = int.tryParse(raw.substring(0, newline)); if (lastUpdate == null) return null; return CacheEntry(json: raw.substring(newline + 1), lastUpdate: lastUpdate); } /// Decodes a cached payload, off the UI isolate when it is large. static Future> decode(String json) => json.length > isolateDecodeThreshold ? compute(_decodeMap, json) : Future.value(_decodeMap(json)); static Map _decodeMap(String json) => jsonDecode(json) as Map; /// Deletes the directory the old localstore-based cache lived in. It is /// never read again and would otherwise linger with up to 200 days of data. static Future deleteLegacyLocalstoreCache() async { try { final docs = await getApplicationDocumentsDirectory(); final legacy = Directory('${docs.path}/MarianumMobile'); if (legacy.existsSync()) await legacy.delete(recursive: true); } on Object catch (e) { debugPrint('Legacy cache cleanup failed: $e'); } } }