Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 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
101 changes: 98 additions & 3 deletions lib/shared/mixins/media_cache_mixin.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,107 @@
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.
class _MediaCacheEntry {
_MediaCacheEntry(this.owner, this.kind, this.messageId, this.bytes);

final 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, 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.
switch (oldest.kind) {
case _MediaKind.image:
oldest.owner._imageCache.remove(oldest.messageId);
case _MediaKind.file:
oldest.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 +110,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 +129,10 @@ mixin MediaCacheMixin {
_imageMetadata.clear();
_fileCache.clear();
_fileMetadata.clear();
_lru.removeWhere((e) {
if (!identical(e.owner, this)) return false;
_totalBytes -= e.bytes;
return true;
});
}
}
83 changes: 78 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,88 @@ 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;
}

/// The daemon sends seconds, the app fills in milliseconds when the field is
/// absent, and both units coexist in the store.
DateTime? _messageTime(int? raw) {
if (raw == null || raw <= 0) return null;
final ms = raw < 1000000000000 ? raw * 1000 : raw;
return DateTime.fromMillisecondsSinceEpoch(ms);
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
Loading
Loading