import 'dart:async'; import 'dart:convert'; import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:path_provider/path_provider.dart'; import '../session/session_manager.dart'; import '../utils/directory_size.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`). /// /// Every account has its own directory; all access goes to the one of the /// active account, in every isolate. class CacheStore { CacheStore._(); static final CacheStore instance = CacheStore._(); /// Payloads above this size are decoded on a background isolate. static const int isolateDecodeThreshold = 64 * 1024; static const _directoryPrefix = 'request_cache'; final Map> _dirs = {}; final Map> _writes = {}; /// Directory name for an account's storage [namespace]; null while no /// account is active. The account from before multi-account support /// (namespace '') keeps the original directory. @visibleForTesting static String directoryName(String? namespace) => switch (namespace) { null => '$_directoryPrefix-signed-out', '' => _directoryPrefix, _ => '$_directoryPrefix-$namespace', }; Future _directory() { final name = directoryName(SessionManager().activeAccount?.namespace); return _dirs[name] ??= () async { final base = await getApplicationCacheDirectory(); final dir = Directory('${base.path}/$name'); 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 { final file = await _file(key); await _writes[file.path]; return parse(await file.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) async { // Resolved up front: a queued write must not follow an account switch // into the next account's directory. final file = await _file(key); final path = file.path; final previous = _writes[path] ?? Future.value(); late final Future current; current = previous.then((_) => _write(file, json)).whenComplete(() { if (identical(_writes[path], current)) _writes.remove(path); }); return _writes[path] = current; } Future _write(File file, String json) async { try { // 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(${file.path}) failed: $e'); } } Future delete(String key) async { try { final file = await _file(key); await _writes[file.path]; await file.delete(); } on Object { // A missing or locked file is as good as deleted for a cache. } } /// Empties the active account's 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'); } _dirs.clear(); } /// Removes the cache of the account with [namespace], active or not. Future deleteNamespace(String namespace) async { final name = directoryName(namespace); try { final base = await getApplicationCacheDirectory(); final dir = Directory('${base.path}/$name'); if (dir.existsSync()) await dir.delete(recursive: true); } on Object catch (e) { debugPrint('CacheStore.deleteNamespace failed: $e'); } _dirs.removeWhere((key, _) => key == name); } /// 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] from every account's cache, /// judged by file mtime so nothing has to be read or decoded. Future deleteOlderThan(Duration maxAge) async { try { final base = await getApplicationCacheDirectory(); final cutoff = DateTime.now().subtract(maxAge); await for (final dir in base.list()) { if (dir is! Directory) continue; final name = dir.uri.pathSegments.lastWhere((s) => s.isNotEmpty); if (!name.startsWith(_directoryPrefix)) continue; 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 => directorySize(await _directory()); /// 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'); } } }