Matthias Nott
9 days ago 90fc31a938afac0f6910c7947c6ecba0adebfea4
fix: immediate disk writes, notification tap skip same session, catch_up trace logging, resume simplification
3 files modified
changed files
lib/providers/providers.dart patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/services/message_store.dart patch | view | blame | history
lib/providers/providers.dart
....@@ -9,6 +9,7 @@
99 import '../models/server_config.dart';
1010 import '../models/session.dart';
1111 import '../services/message_store.dart';
12
+import '../services/trace_service.dart';
1213 import '../services/mqtt_service.dart' show ConnectionStatus;
1314 import '../services/navigate_notifier.dart';
1415
....@@ -99,6 +100,12 @@
99100
100101 /// Switch to a new session and load its messages.
101102 Future<void> switchSession(String sessionId) async {
103
+ // Log caller for debugging
104
+ final trace = StackTrace.current.toString().split('\n').take(4).join(' | ');
105
+ TraceService.instance.addTrace(
106
+ 'switchSession',
107
+ 'from=${_currentSessionId?.substring(0, 8) ?? "null"}(${state.length}) → ${sessionId.substring(0, 8)} | $trace',
108
+ );
102109 // Write current session DIRECTLY to disk (no debounce — prevents data loss)
103110 if (_currentSessionId != null && state.isNotEmpty) {
104111 await MessageStore.writeDirect(_currentSessionId!, state);
....@@ -113,7 +120,8 @@
113120 void addMessage(Message message) {
114121 state = [...state, message];
115122 if (_currentSessionId != null) {
116
- MessageStore.save(_currentSessionId!, state);
123
+ // Write immediately (not debounced) to prevent race with switchSession's loadAll
124
+ MessageStore.writeDirect(_currentSessionId!, state);
117125 }
118126 }
119127
lib/screens/chat_screen.dart
....@@ -223,19 +223,18 @@
223223 // Re-register APNs token after reconnect so daemon always has a fresh token
224224 _push?.onMqttConnected();
225225 };
226
- _ws!.onResume = () async {
227
- // App came back from background — reload messages and catch up.
228
- _chatLog('onResume: reloading messages and sending catch_up');
226
+ _ws!.onResume = () {
227
+ // App came back from background. The in-memory state already has
228
+ // any messages received while suspended (addMessage was called).
229
+ // Just rebuild the UI and scroll to bottom to show them.
230
+ _chatLog('onResume: rebuilding UI and sending catch_up');
229231 _sendCommand('catch_up', {'lastSeq': _lastSeq});
230
- // Force reload current session messages from provider (triggers rebuild)
231
- final activeId = ref.read(activeSessionIdProvider);
232
- if (activeId != null) {
233
- // Re-save then re-load to ensure UI matches persisted state
234
- await ref.read(messagesProvider.notifier).switchSession(activeId);
235
- }
236232 if (mounted) {
237233 setState(() {});
238
- _scrollToBottom();
234
+ // Scroll after the frame rebuilds
235
+ WidgetsBinding.instance.addPostFrameCallback((_) {
236
+ if (mounted) _scrollToBottom();
237
+ });
239238 }
240239 };
241240 _ws!.onError = (error) {
....@@ -260,11 +259,12 @@
260259 // sent immediately if already connected.
261260 _push = PushService(mqttService: _ws!);
262261 _push!.onNotificationTap = (data) {
263
- // Switch to the session immediately, then request catch_up.
264
- // The MQTT connection auto-reconnects on resume, and onResume
265
- // already sends catch_up. We just need to be on the right session.
262
+ // Only switch if tapping a notification for a DIFFERENT session.
263
+ // If already on this session, the message is already displayed —
264
+ // calling switchSession would reload from disk and lose it.
266265 final sessionId = data['sessionId'] as String?;
267
- if (sessionId != null && mounted) {
266
+ final activeId = ref.read(activeSessionIdProvider);
267
+ if (sessionId != null && sessionId != activeId && mounted) {
268268 _switchSession(sessionId);
269269 }
270270 };
....@@ -414,6 +414,7 @@
414414 );
415415 }
416416
417
+ _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} match=${msgSessionId == activeId || msgSessionId == null} content="${content.substring(0, content.length.clamp(0, 40))}"');
417418 if (msgSessionId == null || msgSessionId == activeId) {
418419 // Active session or no session: add directly to chat
419420 ref.read(messagesProvider.notifier).addMessage(message);
lib/services/message_store.dart
....@@ -6,6 +6,7 @@
66 import 'package:path_provider/path_provider.dart';
77
88 import '../models/message.dart';
9
+import 'trace_service.dart';
910
1011 /// Per-session JSON file persistence with debounced saves.
1112 class MessageStore {
....@@ -59,6 +60,8 @@
5960
6061 /// Write directly to disk, bypassing debounce. For critical saves.
6162 static Future<void> writeDirect(String sessionId, List<Message> messages) async {
63
+ // Cancel ALL pending debounce to prevent race with frozen iOS timers
64
+ _debounceTimer?.cancel();
6265 _pendingSaves.remove(sessionId);
6366 await _writeSession(sessionId, messages);
6467 }
....@@ -85,9 +88,11 @@
8588 final file = File('${dir.path}/${_fileForSession(sessionId)}');
8689 // Strip heavy fields for persistence
8790 final lightMessages = messages.map((m) => m.toJsonLight()).toList();
88
- await file.writeAsString(jsonEncode(lightMessages));
91
+ final json = jsonEncode(lightMessages);
92
+ await file.writeAsString(json);
93
+ TraceService.instance.addTrace('MsgStore WRITE', '${sessionId.substring(0, 8)}: ${messages.length} msgs');
8994 } catch (e) {
90
- // Silently fail - message persistence is best-effort
95
+ TraceService.instance.addTrace('MsgStore WRITE ERROR', '${sessionId.substring(0, 8)}: $e');
9196 }
9297 }
9398
....@@ -130,11 +135,14 @@
130135
131136 final jsonStr = await file.readAsString();
132137 final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
133
- return jsonList
138
+ final msgs = jsonList
134139 .map((j) => _messageFromJson(j as Map<String, dynamic>))
135140 .where((m) => !m.isEmptyVoice && !m.isEmptyText)
136141 .toList();
142
+ TraceService.instance.addTrace('MsgStore LOAD', '${sessionId.substring(0, 8)}: ${msgs.length} msgs');
143
+ return msgs;
137144 } catch (e) {
145
+ TraceService.instance.addTrace('MsgStore LOAD ERROR', '${sessionId.substring(0, 8)}: $e');
138146 return [];
139147 }
140148 }