Skip to content
Open
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
27 changes: 27 additions & 0 deletions lib/data/repositories/session_storage.dart
Original file line number Diff line number Diff line change
Expand Up @@ -45,13 +45,40 @@ class SessionStorage extends BaseStorage<Session> {
return Session.fromJson(clone);
}

/// Record-key prefix for pending range-order child sessions, which have no
/// orderId yet and are keyed by their trade public key instead.
static const String pendingChildKeyPrefix = 'pending-child:';

Future<void> putSession(Session session) async {
if (session.orderId == null) {
throw ArgumentError('Cannot store a session with an empty orderId');
}
await putItem(session.orderId!, session);
}

/// Persists a pending range-order child session (no orderId yet), keyed by
/// its trade public key. Persisting it matters for two reasons: the session
/// must survive an app kill between release and the child new-order message,
/// and the background isolate loads sessions from this store to decrypt
/// events addressed to the child trade key.
Future<void> putPendingChildSession(Session session) async {
if (session.orderId != null) {
throw ArgumentError(
'Pending child session must not have an orderId; use putSession',
);
}
if (session.parentOrderId == null) {
throw ArgumentError('Pending child session requires a parentOrderId');
}
await putItem('$pendingChildKeyPrefix${session.tradeKey.public}', session);
}

/// Removes the pending child session record for [tradeKeyPublic], if any.
/// Called once the child order id is known and the session is re-stored
/// under its orderId, or when an expired pending child is cleaned up.
Future<void> deletePendingChildSession(String tradeKeyPublic) =>
deleteItem('$pendingChildKeyPrefix$tradeKeyPublic');

/// Shortcut to get a single session by its ID.
Future<Session?> getSession(String sessionId) => getItem(sessionId);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,13 @@ Future<MostroMessage?> _handleTradeKeyEvent(NostrEvent event, Session session) a
final mostroMessage = MostroMessage.fromJson(result[0]);
mostroMessage.timestamp = event.createdAt?.millisecondsSinceEpoch;

// If this is the new-order confirmation for a pending range-order child
// session, link it to the child order id right here. The foreground link
// (MostroService._maybeLinkChildOrder) never runs while the app is
// backgrounded or killed, and later events for the child order need the
// session stored under its orderId (e.g. role-gated notifications).
await _maybeLinkChildOrder(mostroMessage, session);

// If this event transitions the order to Active, learn the counterpart's
// tradeKey from the Order payload and persist it on the session so the
// background service can immediately subscribe to P2P chat events.
Expand All @@ -340,6 +347,49 @@ Future<MostroMessage?> _handleTradeKeyEvent(NostrEvent event, Session session) a
return mostroMessage;
}

/// Links a pending range-order child session to its concrete child order id
/// when the new-order confirmation arrives while the app is backgrounded.
/// Mirrors MostroService._maybeLinkChildOrder for the background isolate:
/// stores the session under its orderId and drops the pending record.
Future<void> _maybeLinkChildOrder(
MostroMessage message,
Session session,
) async {
if (message.action != mostro_action.Action.newOrder || message.id == null) {
return;
}
if (session.orderId != null || session.parentOrderId == null) {
return;
}

try {
session.orderId = message.id;

final db = await openMostroDatabase('mostro.db');
const secureStorage = FlutterSecureStorage();
final sharedPrefs = SharedPreferencesAsync();
final keyStorage = KeyStorage(
secureStorage: secureStorage,
sharedPrefs: sharedPrefs,
);
final keyDerivator = KeyDerivator("m/44'/1237'/38383'/0");
final keyManager = KeyManager(keyStorage, keyDerivator);
await keyManager.init();
final sessionStorage = SessionStorage(keyManager, db: db);
await sessionStorage.putSession(session);
await sessionStorage.deletePendingChildSession(session.tradeKey.public);

logger.i(
'Background linked child order ${message.id} to parent ${session.parentOrderId}',
);
} catch (e, stackTrace) {
logger.e(
'Failed to link child order in background: $e',
stackTrace: stackTrace,
);
}
}

/// Persists the peer on the given session when [message] is an action that
/// reveals the counterpart's tradeKey (buyer took order / hold invoice
/// payment accepted), and triggers a live chat subscription in the
Expand Down
75 changes: 53 additions & 22 deletions lib/shared/notifiers/session_notifier.dart
Original file line number Diff line number Diff line change
Expand Up @@ -93,29 +93,35 @@ class SessionNotifier extends StateNotifier<List<Session>> {

Future<void> init() async {
final allSessions = await _storage.getAllSessions();
if (_isForever) {
for (final session in allSessions) {
_sessions[session.orderId!] = session;
final cutoff =
DateTime.now().subtract(Duration(hours: _expirationHours));
for (final session in allSessions) {
// Pending range-order child sessions are persisted without an orderId
// (keyed by trade key) so they survive an app kill between release and
// the child new-order message. Restore them into the pending map.
if (session.orderId == null) {
if (_isForever || session.startTime.isAfter(cutoff)) {
_pendingChildSessions[session.tradeKey.public] = session;
} else {
await _storage.deletePendingChildSession(session.tradeKey.public);
}
continue;
}
} else {
final cutoff = DateTime.now()
.subtract(Duration(hours: _expirationHours));
for (final session in allSessions) {
if (session.startTime.isAfter(cutoff)) {

if (_isForever || session.startTime.isAfter(cutoff)) {
_sessions[session.orderId!] = session;
} else {
if (await _isActiveSession(session)) {
logger.i('Skipping cleanup for active session ${session.orderId}');
_sessions[session.orderId!] = session;
} else {
if (await _isActiveSession(session)) {
logger.i('Skipping cleanup for active session ${session.orderId}');
_sessions[session.orderId!] = session;
continue;
}
await _storage.deleteSession(session.orderId!);
_sessions.remove(session.orderId!);
try {
await _cleanupSessionData(session);
} catch (e) {
logger.e('Failed to cleanup data for session ${session.orderId}: $e');
}
continue;
}
await _storage.deleteSession(session.orderId!);
_sessions.remove(session.orderId!);
try {
await _cleanupSessionData(session);
} catch (e) {
logger.e('Failed to cleanup data for session ${session.orderId}: $e');
}
}
}
Expand Down Expand Up @@ -148,6 +154,13 @@ class SessionNotifier extends StateNotifier<List<Session>> {

for (final session in expiredSessions) {
if (session.startTime.isBefore(cutoff)) {
// Expired pending child sessions (no orderId) are keyed by trade key
// and have no associated order data to clean up.
if (session.orderId == null) {
_pendingChildSessions.remove(session.tradeKey.public);
await _storage.deletePendingChildSession(session.tradeKey.public);
continue;
}
if (await _isActiveSession(session)) {
logger.i('Skipping cleanup for active session ${session.orderId}');
continue;
Expand Down Expand Up @@ -205,7 +218,11 @@ class SessionNotifier extends StateNotifier<List<Session>> {
Future<void> saveSession(Session session) async {
_sessions[session.orderId!] = session;
_requestIdToSession.removeWhere((_, value) => identical(value, session));
_pendingChildSessions.remove(session.tradeKey.public);
if (_pendingChildSessions.remove(session.tradeKey.public) != null) {
// The session graduated from pending child to a real order session;
// drop the pending record so it is not restored again on init.
await _storage.deletePendingChildSession(session.tradeKey.public);
}
await _storage.putSession(session);
_emitState();

Expand Down Expand Up @@ -348,6 +365,17 @@ class SessionNotifier extends StateNotifier<List<Session>> {
_pendingChildSessions[tradeKey.public] = session;
_emitState();

// Persist immediately: the session must survive an app kill between
// release and the child new-order message, and the background isolate
// loads sessions from storage to decrypt events addressed to this trade
// key. Without this, child-order events received while the app is not in
// the foreground could never be decrypted (and never notified).
try {
await _storage.putPendingChildSession(session);
} catch (e) {
logger.e('Failed to persist pending child session: $e');
}

logger.i(
'Prepared child session for parent order $parentOrderId using key index $keyIndex',
);
Expand All @@ -372,6 +400,9 @@ class SessionNotifier extends StateNotifier<List<Session>> {
session.orderId = childOrderId;
_sessions[childOrderId] = session;
await _storage.putSession(session);
// The session is now stored under its orderId; drop the pending record so
// it is not restored twice on the next init.
await _storage.deletePendingChildSession(tradeKeyPublic);
_emitState();

logger.i(
Expand Down
Loading
Loading