import 'dart:convert';
import 'package:shared_preferences/shared_preferences.dart';
/// A deliberately small persistence boundary for anonymous public responses.
/// Private/profile/messaging payloads never enter this store because callers
/// must opt into [ApiClient.getPublicJson].
abstract interface class PublicReadCacheStore {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> remove(String key);
Future<Set<String>> keys();
}
class SharedPreferencesPublicReadCacheStore implements PublicReadCacheStore {
const SharedPreferencesPublicReadCacheStore();
Future<SharedPreferences> get _preferences => SharedPreferences.getInstance();
@override
Future<String?> read(String key) async => (await _preferences).getString(key);
@override
Future<void> write(String key, String value) async {
await (await _preferences).setString(key, value);
}
@override
Future<void> remove(String key) async {
await (await _preferences).remove(key);
}
@override
Future<Set<String>> keys() async => (await _preferences).getKeys();
}
class PublicReadCacheEntry {
const PublicReadCacheEntry({
required this.body,
required this.storedAt,
this.etag = '',
this.lastModified = '',
});
final String body;
final DateTime storedAt;
final String etag;
final String lastModified;
PublicReadCacheEntry copyWith({DateTime? storedAt}) => PublicReadCacheEntry(
body: body,
storedAt: storedAt ?? this.storedAt,
etag: etag,
lastModified: lastModified,
);
Map<String, dynamic> toJson() => {
'version': 1,
'body': body,
'stored_at': storedAt.toUtc().millisecondsSinceEpoch,
'etag': etag,
'last_modified': lastModified,
};
factory PublicReadCacheEntry.fromJson(Map<String, dynamic> json) {
final storedAtMs = json['stored_at'];
if (json['body'] is! String || storedAtMs is! num) {
throw const FormatException('Invalid public cache entry');
}
return PublicReadCacheEntry(
body: json['body'] as String,
storedAt: DateTime.fromMillisecondsSinceEpoch(
storedAtMs.toInt(),
isUtc: true,
),
etag: json['etag'] is String ? json['etag'] as String : '',
lastModified: json['last_modified'] is String
? json['last_modified'] as String
: '',
);
}
}
class PublicReadCache {
PublicReadCache({
required this.store,
DateTime Function()? clock,
this.freshFor = const Duration(minutes: 1),
this.staleFor = const Duration(hours: 24),
this.maxEntries = 32,
this.maxEntryBytes = 1024 * 1024,
}) : _clock = clock ?? (() => DateTime.now().toUtc());
// Bump the browser/storage namespace when the public response contract
// gains fields that are required for rendering. Older entries may be
// perfectly valid JSON while still being incomplete for the current UI
// (for example before REST marker.app_url or configurable home items
// existed). Reusing them would silently restore obsolete presentation
// settings until the stale window expires.
static const keyPrefix = 'mw_public_read_v3_';
final PublicReadCacheStore store;
final Duration freshFor;
final Duration staleFor;
final int maxEntries;
final int maxEntryBytes;
final DateTime Function() _clock;
static String keyFor(Uri uri, String locale) {
final identity = '$locale|${uri.toString()}';
return '$keyPrefix${base64UrlEncode(utf8.encode(identity))}';
}
Future<PublicReadCacheEntry?> read(String key) async {
final raw = await store.read(key);
if (raw == null || raw.isEmpty) return null;
try {
final decoded = jsonDecode(raw);
if (decoded is! Map<String, dynamic>) throw const FormatException();
return PublicReadCacheEntry.fromJson(decoded);
} on FormatException {
await store.remove(key);
return null;
}
}
Duration ageOf(PublicReadCacheEntry entry) {
final age = _clock().toUtc().difference(entry.storedAt.toUtc());
return age.isNegative ? Duration.zero : age;
}
DateTime get now => _clock().toUtc();
Future<void> write(String key, PublicReadCacheEntry entry) async {
if (utf8.encode(entry.body).length > maxEntryBytes) return;
await store.write(key, jsonEncode(entry.toJson()));
await _evictIfNeeded();
}
Future<void> invalidate(Uri uri, String locale) =>
store.remove(keyFor(uri, locale));
Future<void> clear() async {
final keys = await store.keys();
for (final key in keys.where((key) => key.startsWith(keyPrefix))) {
await store.remove(key);
}
}
Future<void> _evictIfNeeded() async {
final keys = (await store.keys())
.where((key) => key.startsWith(keyPrefix))
.toList(growable: false);
if (keys.length <= maxEntries) return;
final entries = <String, PublicReadCacheEntry>{};
for (final key in keys) {
final entry = await read(key);
if (entry != null) entries[key] = entry;
}
final ordered = entries.entries.toList()
..sort((a, b) => a.value.storedAt.compareTo(b.value.storedAt));
final removeCount = ordered.length - maxEntries;
for (final entry in ordered.take(removeCount)) {
await store.remove(entry.key);
}
}
}
