Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion lib/features/chat/widgets/encrypted_image_message.dart
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,11 @@ class _EncryptedImageMessageState extends State<EncryptedImageMessage> {
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();
}

Expand Down
107 changes: 104 additions & 3 deletions lib/shared/mixins/media_cache_mixin.dart
Original file line number Diff line number Diff line change
@@ -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<MediaCacheMixin> 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<String, Uint8List> _imageCache = {};
final Map<String, EncryptedImageUploadResult> _imageMetadata = {};
final Map<String, Uint8List> _fileCache = {};
final Map<String, EncryptedFileUploadResult> _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];
Expand All @@ -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];
Expand All @@ -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;
});
}
}
90 changes: 85 additions & 5 deletions lib/shared/providers/app_init_provider.dart
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -35,7 +39,7 @@ final appInitializerProvider = FutureProvider<void>((ref) async {

final sessionManager = ref.read(sessionNotifierProvider.notifier);
await sessionManager.init();

ref.read(subscriptionManagerProvider);

// Start the relay health watchdog: re-engages bootstrap relays and
Expand All @@ -47,19 +51,95 @@ final appInitializerProvider = FutureProvider<void>((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<Status> 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<Order>();
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);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading