Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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
84 changes: 81 additions & 3 deletions lib/shared/mixins/media_cache_mixin.dart
Original file line number Diff line number Diff line change
@@ -1,22 +1,90 @@
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.messageId, this.bytes);

final MediaCacheMixin owner;
final String messageId;
int bytes;
}

/// 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(String messageId, {int? bytes}) {
final index = _lru.indexWhere(
(e) => identical(e.owner, this) && 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, messageId, size)
: (entry..bytes = size));
_totalBytes += size;
_evict();
}

static void _evict() {
while (_totalBytes > mediaCacheMaxBytes && _lru.isNotEmpty) {
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.
oldest.owner._imageCache.remove(oldest.messageId);
oldest.owner._fileCache.remove(oldest.messageId);
}
}

void cacheDecryptedImage(
String messageId, Uint8List data, EncryptedImageUploadResult meta) {
_imageCache[messageId] = data;
_imageMetadata[messageId] = meta;
_mediaTouch(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(messageId);
return data;
}

EncryptedImageUploadResult? getImageMetadata(String messageId) =>
_imageMetadata[messageId];
Expand All @@ -25,11 +93,16 @@ mixin MediaCacheMixin {
String messageId, Uint8List? data, EncryptedFileUploadResult meta) {
if (data != null) {
_fileCache[messageId] = data;
_mediaTouch(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(messageId);
return data;
}

EncryptedFileUploadResult? getFileMetadata(String messageId) =>
_fileMetadata[messageId];
Expand All @@ -39,5 +112,10 @@ mixin MediaCacheMixin {
_imageMetadata.clear();
_fileCache.clear();
_fileMetadata.clear();
_lru.removeWhere((e) {
if (!identical(e.owner, this)) return false;
_totalBytes -= e.bytes;
return true;
});
}
}
74 changes: 70 additions & 4 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,81 @@ 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));

final messageStorage = ref.read(mostroStorageProvider);
for (final session in sessionManager.sessions) {
if(session.orderId == null || (cutoff != null && session.startTime.isBefore(cutoff))) continue;
if (session.orderId == null ||
(cutoff != null && session.startTime.isBefore(cutoff))) {
continue;
}

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.
final latest = await messageStorage.getLatestMessageById(session.orderId!);
if (!isSettledOrderMessage(latest)) {
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.
}
44 changes: 22 additions & 22 deletions pubspec.lock
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,10 @@ packages:
dependency: transitive
description:
name: _fe_analyzer_shared
sha256: da0d9209ca76bde579f2da330aeb9df62b6319c834fa7baae052021b0462401f
sha256: c209688d9f5a5f26b2fb47a188131a6fb9e876ae9e47af3737c0b4f58a93470d
url: "https://pub.dev"
source: hosted
version: "85.0.0"
version: "91.0.0"
_flutterfire_internals:
dependency: transitive
description:
Expand All @@ -21,10 +21,10 @@ packages:
dependency: transitive
description:
name: analyzer
sha256: "974859dc0ff5f37bc4313244b3218c791810d03ab3470a579580279ba971a48d"
sha256: f51c8499b35f9b26820cfe914828a6a98a94efd5cc78b37bb7d03debae3a1d08
url: "https://pub.dev"
source: hosted
version: "7.7.1"
version: "8.4.1"
app_links:
dependency: "direct main"
description:
Expand Down Expand Up @@ -221,10 +221,10 @@ packages:
dependency: transitive
description:
name: characters
sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803
sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b
url: "https://pub.dev"
source: hosted
version: "1.4.0"
version: "1.4.1"
checked_yaml:
dependency: transitive
description:
Expand Down Expand Up @@ -342,10 +342,10 @@ packages:
dependency: transitive
description:
name: dart_style
sha256: "8a0e5fba27e8ee025d2ffb4ee820b4e6e2cf5e4246a6b1a477eb66866947e0bb"
sha256: a9c30492da18ff84efe2422ba2d319a89942d93e58eb0b73d32abe822ef54b7b
url: "https://pub.dev"
source: hosted
version: "3.1.1"
version: "3.1.3"
dbus:
dependency: transitive
description:
Expand Down Expand Up @@ -1073,26 +1073,26 @@ packages:
dependency: transitive
description:
name: matcher
sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2
sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861
url: "https://pub.dev"
source: hosted
version: "0.12.17"
version: "0.12.19"
material_color_utilities:
dependency: transitive
description:
name: material_color_utilities
sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec
sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b"
url: "https://pub.dev"
source: hosted
version: "0.11.1"
version: "0.13.0"
meta:
dependency: transitive
description:
name: meta
sha256: e3641ec5d63ebf0d9b41bd43201a66e3fc79a65db5f61fc181f04cd27aab950c
sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394"
url: "https://pub.dev"
source: hosted
version: "1.16.0"
version: "1.17.0"
mime:
dependency: "direct main"
description:
Expand All @@ -1113,10 +1113,10 @@ packages:
dependency: "direct dev"
description:
name: mockito
sha256: "2314cbe9165bcd16106513df9cf3c3224713087f09723b128928dc11a4379f99"
sha256: eff30d002f0c8bf073b6f929df4483b543133fcafce056870163587b03f1d422
url: "https://pub.dev"
source: hosted
version: "5.5.0"
version: "5.6.4"
mutation_test:
dependency: "direct dev"
description:
Expand Down Expand Up @@ -1655,26 +1655,26 @@ packages:
dependency: "direct dev"
description:
name: test
sha256: "65e29d831719be0591f7b3b1a32a3cda258ec98c58c7b25f7b84241bc31215bb"
sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7"
url: "https://pub.dev"
source: hosted
version: "1.26.2"
version: "1.30.0"
test_api:
dependency: transitive
description:
name: test_api
sha256: "522f00f556e73044315fa4585ec3270f1808a4b186c936e612cab0b565ff1e00"
sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a"
url: "https://pub.dev"
source: hosted
version: "0.7.6"
version: "0.7.10"
test_core:
dependency: transitive
description:
name: test_core
sha256: "80bf5a02b60af04b09e14f6fe68b921aad119493e26e490deaca5993fef1b05a"
sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51"
url: "https://pub.dev"
source: hosted
version: "0.6.11"
version: "0.6.16"
timeago:
dependency: "direct main"
description:
Expand Down
Loading
Loading