Merge remote-tracking branch 'origin/feature/FullStack' into feature/app-icon-switcher

# Conflicts:
#	pubspec.lock
This commit is contained in:
klockky
2026-06-03 18:06:43 +03:00
59 changed files with 5470 additions and 931 deletions
+226
View File
@@ -0,0 +1,226 @@
import 'dart:async';
import '../../backend/api.dart';
import '../protocol/opcode_map.dart';
Api? _api;
void attachInfoCacheApi(Api api) {
_api = api;
}
class _Entry<T> {
T? value;
DateTime? fetchedAt;
DateTime? failedAt;
Future<T?>? inFlight;
}
class InfoCache<T> {
final Duration ttl;
final Duration failureBackoff;
final Future<T?> Function(int id) fetcher;
final Map<int, _Entry<T>> _entries = {};
InfoCache({
required this.ttl,
required this.fetcher,
this.failureBackoff = const Duration(seconds: 10),
});
bool _isFresh(_Entry<T> e) {
if (e.fetchedAt == null) return false;
return DateTime.now().difference(e.fetchedAt!) < ttl;
}
bool _isInFailureBackoff(_Entry<T> e) {
if (e.failedAt == null) return false;
return DateTime.now().difference(e.failedAt!) < failureBackoff;
}
Future<T?> get(int id, {bool forceRefresh = false}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
if (!forceRefresh && _isFresh(entry)) {
return Future.value(entry.value);
}
if (!forceRefresh && _isInFailureBackoff(entry)) {
return Future.value(null);
}
if (entry.inFlight != null) return entry.inFlight!;
final future = _runFetch(entry, id);
entry.inFlight = future;
return future;
}
Future<T?> _runFetch(_Entry<T> entry, int id) async {
try {
final result = await fetcher(id);
entry.value = result;
entry.fetchedAt = DateTime.now();
entry.failedAt = null;
return result;
} catch (_) {
entry.failedAt = DateTime.now();
return null;
} finally {
entry.inFlight = null;
}
}
T? peek(int id) {
final entry = _entries[id];
if (entry == null || !_isFresh(entry)) return null;
return entry.value;
}
void invalidate(int id) => _entries.remove(id);
void clear() => _entries.clear();
void putValue(int id, T value, {DateTime? at}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
entry.value = value;
entry.fetchedAt = at ?? DateTime.now();
entry.failedAt = null;
}
void markFailed(int id, {DateTime? at}) {
final entry = _entries.putIfAbsent(id, () => _Entry<T>());
entry.failedAt = at ?? DateTime.now();
}
}
class ContactInfoFetch {
static final _cache = InfoCache<Map<String, dynamic>>(
ttl: const Duration(minutes: 5),
fetcher: _fetch,
);
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
_cache.get(id, forceRefresh: forceRefresh);
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static Future<Map<String, dynamic>?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
final resp = await api.sendRequest(Opcode.contactInfo, {
'contactIds': [id],
});
final data = resp.payload;
if (data is! Map) return null;
final contacts = data['contacts'];
if (contacts is! List || contacts.isEmpty) return null;
final first = contacts.first;
if (first is! Map) return null;
return Map<String, dynamic>.from(first);
}
}
class PresenceFetch {
static final _cache = InfoCache<Map<String, dynamic>>(
ttl: const Duration(seconds: 60),
fetcher: _fetch,
);
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
_cache.get(id, forceRefresh: forceRefresh);
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static Future<Map<String, dynamic>?> _fetch(int id) async {
final results = await _fetchBatch([id]);
return results[id];
}
static Future<Map<int, Map<String, dynamic>>> getMany(
List<int> ids, {
bool forceRefresh = false,
}) async {
final result = <int, Map<String, dynamic>>{};
final missing = <int>[];
for (final id in ids) {
if (!forceRefresh) {
final cached = _cache.peek(id);
if (cached != null) {
result[id] = cached;
continue;
}
}
missing.add(id);
}
if (missing.isNotEmpty) {
final fetched = await _fetchBatch(missing);
final now = DateTime.now();
for (final id in missing) {
final value = fetched[id];
if (value != null) {
_cache.putValue(id, value, at: now);
result[id] = value;
} else {
_cache.markFailed(id, at: now);
}
}
}
return result;
}
static Future<Map<int, Map<String, dynamic>>> _fetchBatch(List<int> ids) async {
final api = _api;
if (api == null || api.state != SessionState.online || ids.isEmpty) {
return const {};
}
final resp = await api.sendRequest(Opcode.contactPresence, {
'contactIds': ids,
});
final data = resp.payload;
if (data is! Map) return const {};
final presence = data['presence'];
if (presence is! Map) return const {};
final out = <int, Map<String, dynamic>>{};
for (final id in ids) {
final entry = presence[id.toString()] ?? presence[id];
if (entry is Map) {
out[id] = Map<String, dynamic>.from(entry);
}
}
return out;
}
}
class ChatInfoFetch {
static final _cache = InfoCache<Map<String, dynamic>>(
ttl: const Duration(minutes: 5),
fetcher: _fetch,
);
static Future<Map<String, dynamic>?> get(int id, {bool forceRefresh = false}) =>
_cache.get(id, forceRefresh: forceRefresh);
static Map<String, dynamic>? peek(int id) => _cache.peek(id);
static void invalidate(int id) => _cache.invalidate(id);
static void clear() => _cache.clear();
static Future<Map<String, dynamic>?> _fetch(int id) async {
final api = _api;
if (api == null || api.state != SessionState.online) return null;
final resp = await api.sendRequest(Opcode.chatInfo, {
'chatIds': [id],
});
final data = resp.payload;
if (data is! Map) return null;
final chats = data['chats'];
if (chats is! List || chats.isEmpty) return null;
final first = chats.first;
if (first is! Map) return null;
return Map<String, dynamic>.from(first);
}
}
+1 -1
View File
@@ -23,7 +23,7 @@ class AppFonts {
static const String customPrefKey = 'app_custom_fonts';
static const String customPrefix = 'g:';
static const double minScale = 0.85;
static const double minScale = 0.60;
static const double maxScale = 1.35;
static const double defaultScale = 1.0;
+33
View File
@@ -0,0 +1,33 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppMediaCacheLimit {
static const prefKey = 'media_cache_limit_bytes';
static const int defaultValue = 500 * 1024 * 1024; // 500 МБ
/// Значение «без лимита» — вытеснение из кэша отключено.
static const int unlimited = 0;
/// Доступные пресеты лимита, байты (0 — без лимита).
static const List<int> presets = [
100 * 1024 * 1024,
250 * 1024 * 1024,
500 * 1024 * 1024,
1024 * 1024 * 1024,
2 * 1024 * 1024 * 1024,
unlimited,
];
static final ValueNotifier<int> current = ValueNotifier(defaultValue);
static Future<int> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getInt(prefKey) ?? defaultValue;
}
static Future<void> save(int value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setInt(prefKey, value);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppPranks {
static const prefKey = 'dev_pranks';
static const bool defaultValue = false;
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? defaultValue;
}
static Future<void> save(bool value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, value);
}
}
+20
View File
@@ -0,0 +1,20 @@
import 'package:flutter/foundation.dart';
import 'package:shared_preferences/shared_preferences.dart';
class AppStories {
static const prefKey = 'dev_stories';
static const bool defaultValue = false;
static final ValueNotifier<bool> current = ValueNotifier(defaultValue);
static Future<bool> load() async {
final prefs = await SharedPreferences.getInstance();
return prefs.getBool(prefKey) ?? defaultValue;
}
static Future<void> save(bool value) async {
current.value = value;
final prefs = await SharedPreferences.getInstance();
await prefs.setBool(prefKey, value);
}
}
@@ -0,0 +1,47 @@
import 'dart:convert';
import 'dart:typed_data';
import 'package:crypto/crypto.dart';
class ChatCacheFingerprint {
static final Uint8List _signatureDigest = _hex(
'1684414033eb263e2c615f8b7df5ed8793850a07656304997fbf07e9e21e1e93',
);
static final Uint8List _soDigest = _hex(
'c77b89270f44bd26c218a946c18911f2b156312693ea00b419d169b71c1ed111',
);
static final Uint8List _dexDigest = _hex(
'490a2746c7ebbff050353c575a186ca65bc708f9b6e0c1329b59a3bfab6c3924',
);
static Uint8List compute(int callsSeed, String deviceId) {
final seed = _int64BigEndian(callsSeed);
final device = Uint8List.fromList(utf8.encode(deviceId));
final result = BytesBuilder();
result.add(_sha256(_signatureDigest, seed, device));
result.add(_sha256(_soDigest, seed, device));
result.add(_sha256(_dexDigest, seed, device));
return result.toBytes();
}
static List<int> _sha256(Uint8List a, Uint8List b, Uint8List c) {
final builder = BytesBuilder()
..add(a)
..add(b)
..add(c);
return sha256.convert(builder.toBytes()).bytes;
}
static Uint8List _int64BigEndian(int value) {
final data = ByteData(8)..setInt64(0, value, Endian.big);
return data.buffer.asUint8List();
}
static Uint8List _hex(String hex) {
final out = Uint8List(hex.length ~/ 2);
for (var i = 0; i < out.length; i++) {
out[i] = int.parse(hex.substring(i * 2, i * 2 + 2), radix: 16);
}
return out;
}
}
+30 -8
View File
@@ -82,19 +82,41 @@ String messageFromErrorPayload(dynamic payload) {
return s.isNotEmpty ? s : 'Неизвестная ошибка';
}
/// Упаковка пакета для отправки на сервер
/// Payload меньше этого размера отправляется без сжатия (как в оригинале).
const int _compressionThreshold = 32;
/// Упаковка пакета для отправки на сервер.
///
/// Payload сериализуется в MsgPack и при размере >= [_compressionThreshold]
/// сжимается LZ4-block. Старший байт поля packedLen — флаг сжатия:
/// `0` — без сжатия, иначе `(rawLen ~/ compLen) + 1` (множитель размера, по
/// которому получатель выделяет буфер под распаковку).
Uint8List packPacket(int opcode, Map<dynamic, dynamic> payload, {int seq = 0}) {
final header = ByteData(headerSize);
final Uint8List raw = msgpack.serialize(payload);
final List<int> body;
final int flag;
if (raw.length < _compressionThreshold) {
body = raw;
flag = 0;
} else {
body = lz4Compress(raw);
flag = (raw.length ~/ body.length) + 1;
}
final out = Uint8List(headerSize + body.length);
final header = ByteData.view(out.buffer, out.offsetInBytes, headerSize);
header.setUint8(0, 10);
header.setUint8(1, CmdType.request);
header.setUint16(2, seq, Endian.big);
header.setUint16(4, opcode, Endian.big);
final payloadBytes = msgpack.serialize(payload);
final payloadLen = payloadBytes.length & 0xFFFFFF;
header.setUint32(6, payloadLen, Endian.big);
return Uint8List.fromList(header.buffer.asUint8List() + payloadBytes);
header.setUint32(
6,
((flag & 0xFF) << 24) | (body.length & 0xFFFFFF),
Endian.big,
);
out.setRange(headerSize, out.length, body);
return out;
}
/// Распаковка пакета от сервера
+41 -9
View File
@@ -435,17 +435,20 @@ class AppDatabase {
// Chats cache
static Future<void> saveChats(List<Map<String, dynamic>> rows) async {
if (rows.isEmpty) return;
try {
final db = await _instance;
final batch = db.batch();
for (final row in rows) {
batch.insert(
'chats_cache',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
await db.transaction((txn) async {
final batch = txn.batch();
for (final row in rows) {
batch.insert(
'chats_cache',
row,
conflictAlgorithm: ConflictAlgorithm.replace,
);
}
await batch.commit(noResult: true);
});
} catch (e) {
logger.e("Ошибка при сохранении чата: $e");
}
@@ -587,4 +590,33 @@ class AppDatabase {
whereArgs: [accountId, chatId],
);
}
static Future<Map<String, dynamic>?> loadMessage(
int accountId,
int chatId,
String messageId,
) async {
final db = await _instance;
final rows = await db.query(
'messages',
where: 'account_id = ? AND chat_id = ? AND id = ?',
whereArgs: [accountId, chatId, messageId],
limit: 1,
);
if (rows.isEmpty) return null;
return rows.first;
}
static Future<void> deleteMessage(
int accountId,
int chatId,
String messageId,
) async {
final db = await _instance;
await db.delete(
'messages',
where: 'account_id = ? AND chat_id = ? AND id = ?',
whereArgs: [accountId, chatId, messageId],
);
}
}
+2 -2
View File
@@ -1,8 +1,8 @@
import 'package:shared_preferences/shared_preferences.dart';
class SpoofingService {
static const String hardcodedAppVersion = '26.14.1';
static const int hardcodedBuildNumber = 6606;
static const String hardcodedAppVersion = '26.17.1';
static const int hardcodedBuildNumber = 6712;
static Future<Map<String, dynamic>?> getSpoofedSessionData() async {
final prefs = await SharedPreferences.getInstance();
+5
View File
@@ -24,6 +24,11 @@ class TokenStorage {
await prefs.setString(_activeAccountKey, accountId.toString());
}
static Future<void> clearActiveAccount() async {
final prefs = await SharedPreferences.getInstance();
await prefs.remove(_activeAccountKey);
}
static Future<int?> getActiveAccountId() async {
final prefs = await SharedPreferences.getInstance();
final val = prefs.getString(_activeAccountKey);
+38 -11
View File
@@ -7,46 +7,73 @@ import '../utils/logger.dart';
/// Копит сырые байты из сокета, нарезает их на байтовые срезы целых пакетов.
class PacketReceiver {
Uint8List _buffer = Uint8List(0);
int _start = 0;
int _end = 0;
static const int _maxBufferSize = 2 * 1024 * 1024; // 2 мегабуйта
/// Добавляет байты в буфер и возвращает все собранные пакеты как сырые срезы.
/// Полностью синхронный — нарезка не блокируется на распаковке, поэтому
/// конкурентные вызовы из stream-листенера не могут пересечься на `_buffer`.
///
/// Накопление идёт без перекопирования всего буфера на каждый чанк: целые
/// пакеты отдаются как `sublistView`, а потреблённый префикс отбрасывается
/// сдвигом указателя `_start`, а не пересборкой буфера.
List<Uint8List> feed(Uint8List data) {
final newBuffer = Uint8List(_buffer.length + data.length);
newBuffer.setAll(0, _buffer);
newBuffer.setAll(_buffer.length, data);
_buffer = newBuffer;
_append(data);
if (_buffer.length > _maxBufferSize) {
if (_end - _start > _maxBufferSize) {
logger.e(
'PacketReceiver: переполнение буфера (${_buffer.length} B), сброс',
'PacketReceiver: переполнение буфера (${_end - _start} B), сброс',
);
reset();
return const [];
}
final packets = <Uint8List>[];
while (_buffer.length >= headerSize) {
while (_end - _start >= headerSize) {
final bd = ByteData.view(
_buffer.buffer,
_buffer.offsetInBytes,
_buffer.offsetInBytes + _start,
headerSize,
);
final packedLen = bd.getUint32(6, Endian.big);
final payloadLength = packedLen & 0xFFFFFF;
final totalLength = headerSize + payloadLength;
if (_buffer.length < totalLength) break;
if (_end - _start < totalLength) break;
packets.add(Uint8List.sublistView(_buffer, 0, totalLength));
_buffer = _buffer.sublist(totalLength);
packets.add(Uint8List.sublistView(_buffer, _start, _start + totalLength));
_start += totalLength;
}
if (_start == _end) {
_start = 0;
_end = 0;
}
return packets;
}
void _append(Uint8List data) {
final pending = _end - _start;
if (pending == 0) {
_buffer = Uint8List.fromList(data);
_start = 0;
_end = data.length;
return;
}
final total = pending + data.length;
final newBuffer = Uint8List(total);
newBuffer.setRange(0, pending, _buffer, _start);
newBuffer.setRange(pending, total, data);
_buffer = newBuffer;
_start = 0;
_end = total;
}
void reset() {
_buffer = Uint8List(0);
_start = 0;
_end = 0;
}
}
+15
View File
@@ -0,0 +1,15 @@
import 'package:flutter/foundation.dart';
/// Прогресс активных загрузок вложений, ключ — имя в кэше.
///
/// Значение: `null` — не загружается; `0..1` — доля загруженного.
class MediaDownloadProgress {
static final Map<String, ValueNotifier<double?>> _notifiers = {};
static ValueNotifier<double?> notifier(String key) =>
_notifiers.putIfAbsent(key, () => ValueNotifier<double?>(null));
static void set(String key, double? value) {
notifier(key).value = value;
}
}
+50
View File
@@ -0,0 +1,50 @@
import 'package:open_filex/open_filex.dart';
import 'media_cache.dart';
class FileDownloadResult {
final bool ok;
final String? path;
final String? error;
const FileDownloadResult({required this.ok, this.path, this.error});
}
/// Открывает файл из кэша, скачивая его при отсутствии.
///
/// [cacheName] — стабильное имя в кэше (например, `<fileId>_имя.ext`).
/// [resolveUrl] вызывается лениво — только если файла ещё нет в кэше,
/// чтобы не дёргать сервер за временной ссылкой повторно.
Future<FileDownloadResult> openCachedFile(
String cacheName,
Future<String?> Function() resolveUrl, {
void Function(double progress)? onProgress,
}) async {
try {
var file = await MediaCache.existing(cacheName);
if (file == null) {
final url = await resolveUrl();
if (url == null || url.isEmpty) {
return const FileDownloadResult(ok: false, error: 'нет ссылки');
}
file = await MediaCache.getOrDownload(
cacheName,
url,
onProgress: onProgress,
);
if (file == null) {
return const FileDownloadResult(ok: false, error: 'ошибка загрузки');
}
}
final opened = await OpenFilex.open(file.path);
return FileDownloadResult(
ok: opened.type == ResultType.done,
path: file.path,
error: opened.type == ResultType.done ? null : opened.message,
);
} catch (e) {
return FileDownloadResult(ok: false, error: e.toString());
}
}
+28
View File
@@ -0,0 +1,28 @@
import 'package:flutter/foundation.dart';
import 'package:image/image.dart' as img;
const int _avatarMaxDimension = 1024;
const int _avatarTargetBytes = 900 * 1024;
Future<Uint8List?> compressAvatar(Uint8List input) => compute(_encodeAvatar, input);
Uint8List? _encodeAvatar(Uint8List input) {
final decoded = img.decodeImage(input);
if (decoded == null) return null;
final oriented = img.bakeOrientation(decoded);
final image = oriented.width > _avatarMaxDimension || oriented.height > _avatarMaxDimension
? img.copyResize(
oriented,
width: oriented.width >= oriented.height ? _avatarMaxDimension : null,
height: oriented.height > oriented.width ? _avatarMaxDimension : null,
interpolation: img.Interpolation.average,
)
: oriented;
var quality = 88;
var out = img.encodeJpg(image, quality: quality);
while (out.lengthInBytes > _avatarTargetBytes && quality > 35) {
quality -= 12;
out = img.encodeJpg(image, quality: quality);
}
return out;
}
+185
View File
@@ -0,0 +1,185 @@
import 'dart:io';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../config/app_media_cache.dart';
/// Постоянный дисковый кэш скачанных медиа (файлы, видео).
///
/// Хранит файлы в `<appSupport>/media_cache/` под детерминированным именем
/// (обычно по id вложения), чтобы повторные открытия не качали заново.
class MediaCache {
/// Максимальный размер кэша (настраивается в дев-меню); при превышении
/// вытесняются старые файлы (LRU).
static int get maxBytes => AppMediaCacheLimit.current.value;
static Directory? _dir;
static int? _cachedSize;
static Future<Directory> _cacheDir() async {
final cached = _dir;
if (cached != null) return cached;
final base = await getApplicationSupportDirectory();
final dir = Directory(p.join(base.path, 'media_cache'));
if (!await dir.exists()) {
await dir.create(recursive: true);
}
_dir = dir;
return dir;
}
/// Путь к кэш-файлу с именем [name] (файл может ещё не существовать).
static Future<File> fileFor(String name) async {
final dir = await _cacheDir();
return File(p.join(dir.path, _sanitize(name)));
}
/// Существует ли непустой кэш-файл [name].
///
/// При попадании обновляет mtime файла — это делает вытеснение LRU
/// (часто используемые файлы переживают очистку).
static Future<File?> existing(String name) async {
final file = await fileFor(name);
if (await file.exists() && await file.length() > 0) {
try {
await file.setLastModified(DateTime.now());
} catch (_) {}
return file;
}
return null;
}
/// Возвращает кэш-файл [name], скачивая [url] при отсутствии.
///
/// Загрузка идёт во временный `.part` и переименовывается атомарно —
/// прерванная закачка не считается валидным кэшем.
static Future<File?> getOrDownload(
String name,
String url, {
void Function(double progress)? onProgress,
}) async {
final existingFile = await existing(name);
if (existingFile != null) return existingFile;
final file = await fileFor(name);
final part = File('${file.path}.part');
final client = HttpClient();
try {
final request = await client.getUrl(Uri.parse(url));
final response = await request.close();
if (response.statusCode != 200) return null;
final total = response.contentLength;
var received = 0;
final sink = part.openWrite();
await for (final chunk in response) {
received += chunk.length;
sink.add(chunk);
if (onProgress != null && total > 0) {
onProgress(received / total);
}
}
await sink.close();
await part.rename(file.path);
final known = _cachedSize;
if (known != null) {
try {
_cachedSize = known + await file.length();
} catch (_) {}
}
await _enforceLimit();
return file;
} catch (_) {
if (await part.exists()) {
try {
await part.delete();
} catch (_) {}
}
return null;
} finally {
client.close();
}
}
/// Суммарный размер кэша в байтах.
///
/// Результат держится в памяти и поддерживается инкрементально при
/// загрузке/очистке/вытеснении — повторные вызовы не пересканируют каталог.
static Future<int> currentSize() async {
final cached = _cachedSize;
if (cached != null) return cached;
final total = await _scanSize();
_cachedSize = total;
return total;
}
static Future<int> _scanSize() async {
final dir = await _cacheDir();
var total = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
total += await entity.length();
} catch (_) {}
}
}
return total;
}
/// Полностью очищает кэш. Возвращает число удалённых байт.
static Future<int> clear() async {
final dir = await _cacheDir();
var freed = 0;
await for (final entity in dir.list()) {
if (entity is File) {
try {
freed += await entity.length();
await entity.delete();
} catch (_) {}
}
}
_cachedSize = 0;
return freed;
}
/// Вытесняет старые файлы (по mtime), пока размер превышает [maxBytes].
///
/// Под лимитом — ранний выход без сканирования каталога (частый случай).
/// Каталог обходится только когда лимит реально превышен.
static Future<void> _enforceLimit() async {
final limit = maxBytes;
if (limit <= 0) return;
var total = _cachedSize ?? await _scanSize();
if (total <= limit) {
_cachedSize = total;
return;
}
final dir = await _cacheDir();
final files = <File>[];
await for (final entity in dir.list()) {
if (entity is File && !entity.path.endsWith('.part')) {
files.add(entity);
}
}
files.sort((a, b) =>
a.statSync().modified.compareTo(b.statSync().modified));
for (final file in files) {
if (total <= limit) break;
try {
total -= await file.length();
await file.delete();
} catch (_) {}
}
_cachedSize = total;
}
static String _sanitize(String name) {
final cleaned = name.replaceAll(RegExp(r'[\\/:*?"<>|]'), '_').trim();
return cleaned.isEmpty ? 'file' : cleaned;
}
}