Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
27 changes: 27 additions & 0 deletions lib/shared/mixins/media_cache_mixin.dart
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,38 @@ import 'package:mostro_mobile/services/encrypted_file_upload_service.dart';
/// Shared media cache for decrypted images and files.
/// Used by both ChatRoomNotifier (P2P) and DisputeChatNotifier.
mixin MediaCacheMixin {
/// Combined byte budget for decrypted media. These notifiers live for the
/// whole app run, so an unbounded cache held every decrypted photo and
/// file forever; the oldest entries are evicted once the budget is hit
/// (they re-decrypt on demand from the stored blob).
static const int mediaCacheMaxBytes = 32 * 1024 * 1024;

final Map<String, Uint8List> _imageCache = {};
final Map<String, EncryptedImageUploadResult> _imageMetadata = {};
final Map<String, Uint8List> _fileCache = {};
final Map<String, EncryptedFileUploadResult> _fileMetadata = {};
final List<String> _mediaLru = [];
int _mediaBytes = 0;

int get debugMediaCacheBytes => _mediaBytes;

void _mediaTouch(String messageId, int addedBytes) {
_mediaLru.remove(messageId);
_mediaLru.add(messageId);
Comment thread
grunch marked this conversation as resolved.
Outdated
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
_mediaBytes += addedBytes;
while (_mediaBytes > mediaCacheMaxBytes && _mediaLru.isNotEmpty) {
final oldest = _mediaLru.removeAt(0);
_mediaBytes -= _imageCache.remove(oldest)?.length ?? 0;
_mediaBytes -= _fileCache.remove(oldest)?.length ?? 0;
}
}

void cacheDecryptedImage(
String messageId, Uint8List data, EncryptedImageUploadResult meta) {
final previous = _imageCache[messageId]?.length ?? 0;
_imageCache[messageId] = data;
_imageMetadata[messageId] = meta;
_mediaTouch(messageId, data.length - previous);
}

Uint8List? getCachedImage(String messageId) => _imageCache[messageId];
Expand All @@ -24,7 +47,9 @@ mixin MediaCacheMixin {
void cacheDecryptedFile(
String messageId, Uint8List? data, EncryptedFileUploadResult meta) {
if (data != null) {
final previous = _fileCache[messageId]?.length ?? 0;
_fileCache[messageId] = data;
_mediaTouch(messageId, data.length - previous);
}
_fileMetadata[messageId] = meta;
}
Expand All @@ -39,5 +64,7 @@ mixin MediaCacheMixin {
_imageMetadata.clear();
_fileCache.clear();
_fileMetadata.clear();
_mediaLru.clear();
_mediaBytes = 0;
}
}
19 changes: 19 additions & 0 deletions lib/shared/providers/app_init_provider.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,6 @@
import 'package:mostro_mobile/shared/providers/mostro_storage_provider.dart';
import 'package:mostro_mobile/data/models/order.dart';
import 'package:mostro_mobile/data/models/mostro_message.dart';
import 'dart:async';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mostro_mobile/core/config.dart';
Expand Down Expand Up @@ -53,13 +56,29 @@ final appInitializerProvider = FutureProvider<void>((ref) async {
? null
: DateTime.now().subtract(Duration(hours: expirationHours));

final messageStorage = ref.read(mostroStorageProvider);
for (final session in sessionManager.sessions) {
if(session.orderId == null || (cutoff != null && session.startTime.isBefore(cutoff))) continue;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

// Terminal orders initialize lazily when a screen watches them: an eager
// notifier per finished trade meant a storage watcher, a book listener
// and (for chats) a history decrypt alive until process exit.
final latest =
await messageStorage.getLatestMessageById(session.orderId!);
if (isTerminalOrderMessage(latest)) continue;
Comment thread
grunch marked this conversation as resolved.
Outdated
Comment thread
grunch marked this conversation as resolved.
Outdated

ref.read(orderNotifierProvider(session.orderId!).notifier);

if (session.peer != null) {
ref.read(chatRoomsProvider(session.orderId!));
}
}
});

/// Whether the order's last stored message reports a terminal status. A
/// missing message or a non-order payload counts as live, so anything
/// ambiguous keeps today's eager behaviour.
bool isTerminalOrderMessage(MostroMessage? message) {
final order = message?.getPayload<Order>();
return order != null && order.status.isTerminal;
}
83 changes: 83 additions & 0 deletions test/shared/lazy_terminal_and_media_cache_test.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import 'dart:typed_data';

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/services/encrypted_image_upload_service.dart';
import 'package:mostro_mobile/shared/mixins/media_cache_mixin.dart';
import 'package:mostro_mobile/shared/providers/app_init_provider.dart';

/// Startup eagerly created an OrderNotifier (storage watcher + listeners) and
/// a ChatRoomNotifier (history decrypt) for EVERY session of the last 30
/// days, terminal or not, and each chat notifier held its decrypted media
/// bytes until process exit. Terminal orders now initialize lazily and the
/// media cache is byte-bounded.
void main() {
MostroMessage withStatus(Status status) => MostroMessage(
action: Action.newOrder,
id: 'o1',
payload: Order(
kind: OrderType.sell,
status: status,
fiatCode: 'VES',
fiatAmount: 100,
paymentMethod: 'cash',
),
);

group('isTerminalOrderMessage', () {
test('terminal statuses skip eager initialization', () {
expect(isTerminalOrderMessage(withStatus(Status.canceled)), isTrue);
expect(isTerminalOrderMessage(withStatus(Status.success)), isTrue);
expect(
isTerminalOrderMessage(withStatus(Status.canceledByAdmin)), isTrue);
});

test('live statuses keep eager initialization', () {
expect(isTerminalOrderMessage(withStatus(Status.pending)), isFalse);
expect(isTerminalOrderMessage(withStatus(Status.active)), isFalse);
expect(isTerminalOrderMessage(withStatus(Status.fiatSent)), isFalse);
});

test('no message or no order payload counts as live (conservative)', () {
expect(isTerminalOrderMessage(null), isFalse);
expect(
isTerminalOrderMessage(MostroMessage(action: Action.rate, id: 'o1')),
isFalse,
);
});
});

group('MediaCacheMixin byte bound', () {
test('evicts the oldest entries once the cap is exceeded', () {
final cache = _CacheHost();
final chunk = Uint8List(MediaCacheMixin.mediaCacheMaxBytes ~/ 3);
final meta = EncryptedImageUploadResult(
blossomUrl: 'https://blossom/x',
nonce: '00',
mimeType: 'image/png',
originalSize: chunk.length,
width: 1,
height: 1,
filename: 'x.png',
encryptedSize: chunk.length,
);

cache.cacheDecryptedImage('a', chunk, meta);
cache.cacheDecryptedImage('b', chunk, meta);
cache.cacheDecryptedImage('c', chunk, meta);
cache.cacheDecryptedImage('d', chunk, meta);

expect(cache.debugMediaCacheBytes,
lessThanOrEqualTo(MediaCacheMixin.mediaCacheMaxBytes));
expect(cache.getCachedImage('a'), isNull,
reason: 'oldest entry must be evicted first');
expect(cache.getCachedImage('d'), isNotNull);
});
});
}

class _CacheHost with MediaCacheMixin {}
Loading