diff --git a/lib/features/chat/widgets/encrypted_image_message.dart b/lib/features/chat/widgets/encrypted_image_message.dart index 9d87fe74f..2c8c90149 100644 --- a/lib/features/chat/widgets/encrypted_image_message.dart +++ b/lib/features/chat/widgets/encrypted_image_message.dart @@ -88,7 +88,11 @@ class _EncryptedImageMessageState extends State { return _buildErrorWidget(); } - // Show loading widget while waiting for initState to trigger the load + // Show loading widget while waiting for initState to trigger the load. + // Also covers a cache miss after the media budget evicted the bytes of a + // still-mounted widget: without re-requesting, this would show the + // placeholder forever. + WidgetsBinding.instance.addPostFrameCallback((_) => _loadImageIfNeeded()); return _buildLoadingWidget(); } diff --git a/lib/shared/mixins/media_cache_mixin.dart b/lib/shared/mixins/media_cache_mixin.dart index 6b012150a..2b71ca1e9 100644 --- a/lib/shared/mixins/media_cache_mixin.dart +++ b/lib/shared/mixins/media_cache_mixin.dart @@ -1,22 +1,113 @@ -import 'dart:typed_data'; +import 'package:flutter/foundation.dart'; import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +/// One cached blob, tracked globally so the budget is a real ceiling rather +/// than a per-conversation one. The owner is held weakly: the list is +/// process-global, and a strong reference would keep a notifier (and every +/// blob in its maps) alive if `dispose()` never ran. +class _MediaCacheEntry { + _MediaCacheEntry(MediaCacheMixin owner, this.kind, this.messageId, this.bytes) + : owner = WeakReference(owner); + + final WeakReference owner; + final _MediaKind kind; + final String messageId; + int bytes; +} + +/// Images and files are separate maps, so one message id may hold both; +/// keying the accounting by kind keeps their bytes from overwriting each +/// other. +enum _MediaKind { image, file } + /// Shared media cache for decrypted images and files. /// Used by both ChatRoomNotifier (P2P) and DisputeChatNotifier. mixin MediaCacheMixin { + /// Combined byte budget for decrypted media, shared by every conversation: + /// these notifiers live for the whole app run, so an unbounded cache held + /// every decrypted photo and file forever, and a per-notifier budget would + /// multiply the ceiling by the number of conversations. The least recently + /// used entries are evicted once the budget is hit (they re-decrypt on + /// demand from the stored blob). + static const int mediaCacheMaxBytes = 32 * 1024 * 1024; + + static final List<_MediaCacheEntry> _lru = []; + static int _totalBytes = 0; + + @visibleForTesting + static int get debugMediaCacheBytes => _totalBytes; + + @visibleForTesting + static void debugResetMediaCache() { + _lru.clear(); + _totalBytes = 0; + } + final Map _imageCache = {}; final Map _imageMetadata = {}; final Map _fileCache = {}; final Map _fileMetadata = {}; + /// Moves an entry to the most-recently-used end. [bytes] is the new size on + /// a write; omitted on a read, which promotes without changing accounting. + void _mediaTouch(_MediaKind kind, String messageId, {int? bytes}) { + final index = _lru.indexWhere( + (e) => + identical(e.owner.target, this) && + e.kind == kind && + e.messageId == messageId, + ); + _MediaCacheEntry? entry; + if (index >= 0) { + entry = _lru.removeAt(index); + _totalBytes -= entry.bytes; + } + final size = bytes ?? entry?.bytes; + // A read miss has nothing to promote. + if (size == null) return; + _lru.add(entry == null + ? _MediaCacheEntry(this, kind, messageId, size) + : (entry..bytes = size)); + _totalBytes += size; + _evict(); + } + + /// Evicts from the least recently used end until the budget is met. The + /// entry just touched (the last one) is never evicted: an entry larger than + /// the whole budget would otherwise evict itself, and the widget that asked + /// for it would re-download and re-decrypt it on every rebuild. + static void _evict() { + while (_totalBytes > mediaCacheMaxBytes && _lru.length > 1) { + final oldest = _lru.removeAt(0); + _totalBytes -= oldest.bytes; + // Metadata is kept: it is small, and the widgets re-request the blob on + // a miss, which re-decrypts and re-caches it. A collected owner has + // already released its maps; only the accounting was left to drop. + final owner = oldest.owner.target; + if (owner == null) continue; + switch (oldest.kind) { + case _MediaKind.image: + owner._imageCache.remove(oldest.messageId); + case _MediaKind.file: + owner._fileCache.remove(oldest.messageId); + } + } + } + void cacheDecryptedImage( String messageId, Uint8List data, EncryptedImageUploadResult meta) { _imageCache[messageId] = data; _imageMetadata[messageId] = meta; + _mediaTouch(_MediaKind.image, messageId, bytes: data.length); } - Uint8List? getCachedImage(String messageId) => _imageCache[messageId]; + Uint8List? getCachedImage(String messageId) { + final data = _imageCache[messageId]; + // Reads promote too, or the cache would evict a hot entry FIFO-style. + if (data != null) _mediaTouch(_MediaKind.image, messageId); + return data; + } EncryptedImageUploadResult? getImageMetadata(String messageId) => _imageMetadata[messageId]; @@ -25,11 +116,16 @@ mixin MediaCacheMixin { String messageId, Uint8List? data, EncryptedFileUploadResult meta) { if (data != null) { _fileCache[messageId] = data; + _mediaTouch(_MediaKind.file, messageId, bytes: data.length); } _fileMetadata[messageId] = meta; } - Uint8List? getCachedFile(String messageId) => _fileCache[messageId]; + Uint8List? getCachedFile(String messageId) { + final data = _fileCache[messageId]; + if (data != null) _mediaTouch(_MediaKind.file, messageId); + return data; + } EncryptedFileUploadResult? getFileMetadata(String messageId) => _fileMetadata[messageId]; @@ -39,5 +135,10 @@ mixin MediaCacheMixin { _imageMetadata.clear(); _fileCache.clear(); _fileMetadata.clear(); + _lru.removeWhere((e) { + if (!identical(e.owner.target, this)) return false; + _totalBytes -= e.bytes; + return true; + }); } } diff --git a/lib/shared/providers/app_init_provider.dart b/lib/shared/providers/app_init_provider.dart index 1e59a5176..2ddda940a 100644 --- a/lib/shared/providers/app_init_provider.dart +++ b/lib/shared/providers/app_init_provider.dart @@ -1,5 +1,9 @@ import 'dart:async'; import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart'; import 'package:mostro_mobile/core/config.dart'; import 'package:mostro_mobile/features/key_manager/key_manager_provider.dart'; import 'package:mostro_mobile/features/chat/providers/chat_room_providers.dart'; @@ -35,7 +39,7 @@ final appInitializerProvider = FutureProvider((ref) async { final sessionManager = ref.read(sessionNotifierProvider.notifier); await sessionManager.init(); - + ref.read(subscriptionManagerProvider); // Start the relay health watchdog: re-engages bootstrap relays and @@ -47,19 +51,95 @@ final appInitializerProvider = FutureProvider((ref) async { }); final settings = ref.read(settingsProvider); - final expirationHours = settings.sessionExpirationHours ?? Config.sessionExpirationHours; + final expirationHours = + settings.sessionExpirationHours ?? Config.sessionExpirationHours; final isForever = expirationHours == 0; final cutoff = isForever ? null : DateTime.now().subtract(Duration(hours: expirationHours)); - for (final session in sessionManager.sessions) { - if(session.orderId == null || (cutoff != null && session.startTime.isBefore(cutoff))) continue; + final messageStorage = ref.read(mostroStorageProvider); + final sessions = sessionManager.sessions + .where((session) => + session.orderId != null && + (cutoff == null || !session.startTime.isBefore(cutoff))) + .toList(); + // One storage lookup per session, issued together rather than awaited one + // by one, so the added startup cost is a single round trip. + final latestMessages = await Future.wait( + sessions.map((s) => messageStorage.getLatestMessageById(s.orderId!)), + ); + + for (var i = 0; i < sessions.length; i++) { + final session = sessions[i]; - ref.read(orderNotifierProvider(session.orderId!).notifier); + // Settled orders initialize lazily when a screen watches them: an eager + // notifier per finished trade meant a storage watcher and a book listener + // alive until process exit. + if (!isSettledOrderMessage(latestMessages[i])) { + ref.read(orderNotifierProvider(session.orderId!).notifier); + } + // The chat notifier stays eager regardless: it is the only consumer of + // SubscriptionManager.chat, a broadcast stream that drops events while + // nobody listens, so a peer message on a finished trade would be lost + // until the user happened to open the Chats tab. if (session.peer != null) { ref.read(chatRoomsProvider(session.orderId!)); } } }); + +/// How long after the last message a finished order still initializes +/// eagerly, so trailing notices (`bond-slashed`, ratings) are reacted to +/// live rather than only persisted. +const Duration settledOrderGrace = Duration(hours: 24); + +/// Statuses after which Mostro sends nothing that needs a live reaction. +/// +/// Deliberately *not* [Status.isTerminal], which answers a different +/// question — whether a session can be deleted during cleanup — and is only +/// ever applied to sessions already past the expiration cutoff. Three of its +/// members still expect traffic and stay eager here: +/// * [Status.settledHoldInvoice] — the window between release and payout, +/// where the buyer may still replace a wrong invoice; +/// * [Status.canceled] — carries the deferred session deletion that +/// `OrderNotifier.sync()` re-arms via `reconcileCanceledBondedSession()`, +/// plus a trailing `bond-slashed` notice; +/// * [Status.success] — the rating exchange, which has no time bound. +const Set settledOrderStatuses = { + Status.canceledByAdmin, + Status.settledByAdmin, + Status.completedByAdmin, + Status.cooperativelyCanceled, + Status.expired, +}; + +/// Whether the order's last stored message reports a settled status old +/// enough that nothing further is expected. A missing message, a non-order +/// payload, an unknown timestamp or a recent one all count as live, so +/// anything ambiguous keeps today's eager behaviour. +bool isSettledOrderMessage(MostroMessage? message, {DateTime? now}) { + final order = message?.getPayload(); + if (order == null || !settledOrderStatuses.contains(order.status)) { + return false; + } + final at = _messageTime(message!.timestamp); + if (at == null) return false; + return (now ?? DateTime.now()).difference(at) > settledOrderGrace; +} + +/// Largest value `DateTime.fromMillisecondsSinceEpoch` accepts; anything +/// past it throws a `RangeError`. +const int _maxMillisecondsSinceEpoch = 8640000000000000; + +/// The daemon sends seconds, the app fills in milliseconds when the field is +/// absent, and both units coexist in the store. A value outside the +/// `DateTime` range is treated as unknown rather than allowed to abort +/// initialization. +DateTime? _messageTime(int? raw) { + if (raw == null || raw <= 0) return null; + final ms = raw < 1000000000000 ? raw * 1000 : raw; + if (ms > _maxMillisecondsSinceEpoch) return null; + return DateTime.fromMillisecondsSinceEpoch(ms); +} diff --git a/test/shared/mixins/media_cache_mixin_test.dart b/test/shared/mixins/media_cache_mixin_test.dart new file mode 100644 index 000000000..7be84455d --- /dev/null +++ b/test/shared/mixins/media_cache_mixin_test.dart @@ -0,0 +1,154 @@ +import 'dart:typed_data'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/services/encrypted_file_upload_service.dart'; +import 'package:mostro_mobile/services/encrypted_image_upload_service.dart'; +import 'package:mostro_mobile/shared/mixins/media_cache_mixin.dart'; + +/// Every chat notifier held its decrypted media bytes until process exit. The +/// cache is now byte-bounded, and the budget is global: a per-notifier +/// ceiling would have been multiplied by the number of conversations. +void main() { + EncryptedImageUploadResult meta(int size) => EncryptedImageUploadResult( + blossomUrl: 'https://blossom/x', + nonce: '00', + mimeType: 'image/png', + originalSize: size, + width: 1, + height: 1, + filename: 'x.png', + encryptedSize: size, + ); + + EncryptedFileUploadResult fileMeta(int size) => EncryptedFileUploadResult( + blossomUrl: 'https://blossom/f', + nonce: '00', + mimeType: 'application/pdf', + fileType: 'pdf', + originalSize: size, + filename: 'f.pdf', + encryptedSize: size, + ); + + Uint8List chunk() => Uint8List(MediaCacheMixin.mediaCacheMaxBytes ~/ 3); + + setUp(MediaCacheMixin.debugResetMediaCache); + + test('evicts the least recently used entries once the cap is exceeded', () { + final cache = _CacheHost(); + final data = chunk(); + + cache.cacheDecryptedImage('a', data, meta(data.length)); + cache.cacheDecryptedImage('b', data, meta(data.length)); + cache.cacheDecryptedImage('c', data, meta(data.length)); + cache.cacheDecryptedImage('d', data, meta(data.length)); + + expect(MediaCacheMixin.debugMediaCacheBytes, + lessThanOrEqualTo(MediaCacheMixin.mediaCacheMaxBytes)); + expect(cache.getCachedImage('a'), isNull, + reason: 'least recently used entry must be evicted first'); + expect(cache.getCachedImage('d'), isNotNull); + }); + + test('a read promotes an entry, so a hot item is not evicted', () { + final cache = _CacheHost(); + final data = chunk(); + + cache.cacheDecryptedImage('a', data, meta(data.length)); + cache.cacheDecryptedImage('b', data, meta(data.length)); + cache.getCachedImage('a'); + cache.cacheDecryptedImage('c', data, meta(data.length)); + cache.cacheDecryptedImage('d', data, meta(data.length)); + + expect(cache.getCachedImage('a'), isNotNull, + reason: 'read recently: FIFO would have evicted it'); + expect(cache.getCachedImage('b'), isNull); + }); + + test('a read does not change the byte accounting', () { + final cache = _CacheHost(); + final data = chunk(); + + cache.cacheDecryptedImage('a', data, meta(data.length)); + final before = MediaCacheMixin.debugMediaCacheBytes; + cache.getCachedImage('a'); + cache.getCachedImage('missing'); + + expect(MediaCacheMixin.debugMediaCacheBytes, before); + }); + + test('the budget is shared across conversations, not per notifier', () { + final first = _CacheHost(); + final second = _CacheHost(); + final data = chunk(); + + first.cacheDecryptedImage('a', data, meta(data.length)); + first.cacheDecryptedImage('b', data, meta(data.length)); + second.cacheDecryptedImage('c', data, meta(data.length)); + second.cacheDecryptedImage('d', data, meta(data.length)); + + expect(MediaCacheMixin.debugMediaCacheBytes, + lessThanOrEqualTo(MediaCacheMixin.mediaCacheMaxBytes)); + expect(first.getCachedImage('a'), isNull, + reason: 'a second conversation must not double the ceiling'); + }); + + test( + 'an entry larger than the budget stays cached instead of evicting itself', + () { + // Evicting the entry just inserted made the image widget re-download and + // re-decrypt on every frame: cache miss -> load -> cache -> self-evict -> + // setState -> cache miss again. + final cache = _CacheHost(); + final huge = Uint8List(MediaCacheMixin.mediaCacheMaxBytes + 1); + + cache.cacheDecryptedImage('huge', huge, meta(huge.length)); + + expect(cache.getCachedImage('huge'), isNotNull); + expect(MediaCacheMixin.debugMediaCacheBytes, huge.length); + }); + + test('an oversized entry still evicts everything older than itself', () { + final cache = _CacheHost(); + final data = chunk(); + final huge = Uint8List(MediaCacheMixin.mediaCacheMaxBytes + 1); + + cache.cacheDecryptedImage('a', data, meta(data.length)); + cache.cacheDecryptedImage('huge', huge, meta(huge.length)); + + expect(cache.getCachedImage('a'), isNull); + expect(MediaCacheMixin.debugMediaCacheBytes, huge.length); + }); + + test('an image and a file under the same message id are tracked separately', + () { + final cache = _CacheHost(); + final image = Uint8List(4); + final file = Uint8List(1); + + cache.cacheDecryptedImage('m', image, meta(image.length)); + cache.cacheDecryptedFile('m', file, fileMeta(file.length)); + + expect(MediaCacheMixin.debugMediaCacheBytes, image.length + file.length); + expect(cache.getCachedImage('m'), isNotNull); + expect(cache.getCachedFile('m'), isNotNull); + + cache.clearMediaCaches(); + expect(MediaCacheMixin.debugMediaCacheBytes, 0); + }); + + test('clearing one conversation releases only its own bytes', () { + final first = _CacheHost(); + final second = _CacheHost(); + final data = chunk(); + + first.cacheDecryptedImage('a', data, meta(data.length)); + second.cacheDecryptedImage('b', data, meta(data.length)); + first.clearMediaCaches(); + + expect(MediaCacheMixin.debugMediaCacheBytes, data.length); + expect(second.getCachedImage('b'), isNotNull); + }); +} + +class _CacheHost with MediaCacheMixin {} diff --git a/test/shared/providers/app_init_provider_test.dart b/test/shared/providers/app_init_provider_test.dart new file mode 100644 index 000000000..9608d82c4 --- /dev/null +++ b/test/shared/providers/app_init_provider_test.dart @@ -0,0 +1,111 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mostro_mobile/data/models/enums/action.dart'; +import 'package:mostro_mobile/data/models/enums/order_type.dart'; +import 'package:mostro_mobile/data/models/enums/status.dart'; +import 'package:mostro_mobile/data/models/mostro_message.dart'; +import 'package:mostro_mobile/data/models/order.dart'; +import 'package:mostro_mobile/shared/providers/app_init_provider.dart'; + +/// Startup eagerly created an OrderNotifier (storage watcher + book listener) +/// for EVERY session of the last 30 days, settled or not. Settled orders now +/// initialize lazily — but only those after which Mostro sends nothing that +/// needs a live reaction, and only once the trailing-notice window has +/// passed. `Status.isTerminal` answers a different question (may this session +/// be deleted during cleanup?) and is not reused here. +void main() { + final now = DateTime(2026, 9, 1, 12); + + MostroMessage withStatus(Status status, + {Duration age = const Duration(days: 3)}) { + final message = MostroMessage( + action: Action.newOrder, + id: 'o1', + payload: Order( + kind: OrderType.sell, + status: status, + fiatCode: 'VES', + fiatAmount: 100, + paymentMethod: 'cash', + ), + ); + message.timestamp = now.subtract(age).millisecondsSinceEpoch; + return message; + } + + group('isSettledOrderMessage', () { + test('settled statuses past the grace window initialize lazily', () { + for (final status in settledOrderStatuses) { + expect(isSettledOrderMessage(withStatus(status), now: now), isTrue, + reason: '$status expects no further traffic'); + } + }); + + test('a settled order still inside the grace window stays eager', () { + // Trailing notices (bond-slashed, ratings) must still be reacted to + // live, not only persisted. + expect( + isSettledOrderMessage( + withStatus(Status.canceledByAdmin, age: const Duration(hours: 1)), + now: now), + isFalse, + ); + }); + + test('statuses that still expect traffic stay eager', () { + // settled-hold-invoice: the buyer may still replace a wrong invoice. + expect( + isSettledOrderMessage(withStatus(Status.settledHoldInvoice), + now: now), + isFalse); + // canceled: OrderNotifier.sync() re-arms the deferred session deletion + // through reconcileCanceledBondedSession(), and bond-slashed trails it. + expect(isSettledOrderMessage(withStatus(Status.canceled), now: now), + isFalse); + // success: the rating exchange has no time bound. + expect( + isSettledOrderMessage(withStatus(Status.success), now: now), isFalse); + }); + + test('live statuses keep eager initialization', () { + expect( + isSettledOrderMessage(withStatus(Status.pending), now: now), isFalse); + expect( + isSettledOrderMessage(withStatus(Status.active), now: now), isFalse); + expect(isSettledOrderMessage(withStatus(Status.fiatSent), now: now), + isFalse); + }); + + test('anything ambiguous counts as live (conservative)', () { + expect(isSettledOrderMessage(null, now: now), isFalse); + expect( + isSettledOrderMessage(MostroMessage(action: Action.rate, id: 'o1'), + now: now), + isFalse, + reason: 'no order payload', + ); + final noTimestamp = withStatus(Status.expired); + noTimestamp.timestamp = null; + expect(isSettledOrderMessage(noTimestamp, now: now), isFalse, + reason: 'an unknown age is not proof that nothing is coming'); + }); + + test('a seconds timestamp is read in the right unit', () { + // The daemon sends seconds; the app fills in milliseconds only when the + // field is absent, so both units coexist in the store. + final message = withStatus(Status.expired); + message.timestamp = + now.subtract(const Duration(hours: 1)).millisecondsSinceEpoch ~/ 1000; + expect(isSettledOrderMessage(message, now: now), isFalse, + reason: 'one hour old: still inside the grace window'); + }); + + test('a timestamp beyond the DateTime range counts as live, not a crash', + () { + // DateTime.fromMillisecondsSinceEpoch throws past 8640000000000000 ms; + // a corrupt stored value must not abort app initialization. + final message = withStatus(Status.expired); + message.timestamp = 8640000000000001; + expect(isSettledOrderMessage(message, now: now), isFalse); + }); + }); +}