2 files added
11 files modified
| .. | .. |
|---|
| 1 | +# PAILot Message System Rewrite Spec |
|---|
| 2 | + |
|---|
| 3 | +## Problem |
|---|
| 4 | + |
|---|
| 5 | +The current message handling has accumulated race conditions from incremental fixes: |
|---|
| 6 | +- `switchSession` is async — messages arriving during the async gap get overwritten by `loadAll` |
|---|
| 7 | +- iOS kills the MQTT socket in background but the client reports "connected" |
|---|
| 8 | +- File corruption from concurrent read/write on the same session file |
|---|
| 9 | +- Notification tap triggers `switchSession` which reloads from disk, losing in-memory messages |
|---|
| 10 | +- `addMessage` and `switchSession` compete on the same state and disk files |
|---|
| 11 | + |
|---|
| 12 | +## Architecture: Single Message Bus |
|---|
| 13 | + |
|---|
| 14 | +### 1. One MQTT topic for everything |
|---|
| 15 | + |
|---|
| 16 | +- **Server**: publish ALL outbound messages to `pailot/out` (DONE) |
|---|
| 17 | +- **App**: subscribe to `pailot/out` + `pailot/sessions` + `pailot/status` + `pailot/control/out` |
|---|
| 18 | +- Every message carries `type`, `sessionId`, `seq` in the payload |
|---|
| 19 | +- Client routes by `sessionId` — never by topic |
|---|
| 20 | + |
|---|
| 21 | +### 2. MessageStore redesign |
|---|
| 22 | + |
|---|
| 23 | +Replace per-session debounced saves with a single append-only log: |
|---|
| 24 | + |
|---|
| 25 | +``` |
|---|
| 26 | +~/.../messages/log.jsonl — append-only, one JSON line per message |
|---|
| 27 | +~/.../messages/index.json — { sessionId: [lineNumbers] } for fast lookup |
|---|
| 28 | +``` |
|---|
| 29 | + |
|---|
| 30 | +**Operations:** |
|---|
| 31 | +- `append(message)` — append one line to log.jsonl (sync, atomic, no race) |
|---|
| 32 | +- `loadSession(sessionId)` — read index, seek to lines, return messages |
|---|
| 33 | +- `compact()` — rewrite log removing old messages (run on app start, not during use) |
|---|
| 34 | + |
|---|
| 35 | +**Benefits:** |
|---|
| 36 | +- No per-session files — no file-level races |
|---|
| 37 | +- Append-only — no read-modify-write cycle |
|---|
| 38 | +- No debounce needed — each append is a single `writeAsStringSync` with `mode: FileMode.append` |
|---|
| 39 | + |
|---|
| 40 | +### 3. Connection state machine |
|---|
| 41 | + |
|---|
| 42 | +``` |
|---|
| 43 | +States: disconnected → connecting → connected → suspended → reconnecting → connected |
|---|
| 44 | + ↑ | |
|---|
| 45 | + └──────────────────────────┘ |
|---|
| 46 | + |
|---|
| 47 | +Transitions: |
|---|
| 48 | +- App launch: disconnected → connecting → connected |
|---|
| 49 | +- App background: connected → suspended (keep client, mark state) |
|---|
| 50 | +- App resume: suspended → reconnecting → connected (always force-reconnect) |
|---|
| 51 | +- Connection lost: connected → reconnecting → connected (autoReconnect) |
|---|
| 52 | +- User disconnect: any → disconnected |
|---|
| 53 | +``` |
|---|
| 54 | + |
|---|
| 55 | +**Key rule:** In `suspended` state, do NOT process buffered MQTT messages. Process them only after reconnect + catch_up completes. This prevents the race where a buffered message is added to state before `loadAll` overwrites it. |
|---|
| 56 | + |
|---|
| 57 | +### 4. Message routing (synchronous, no async gaps) |
|---|
| 58 | + |
|---|
| 59 | +```dart |
|---|
| 60 | +void _onMessage(Map<String, dynamic> json) { |
|---|
| 61 | + final type = json['type'] as String?; |
|---|
| 62 | + final sessionId = json['sessionId'] as String?; |
|---|
| 63 | + final currentId = _currentSessionId; |
|---|
| 64 | + |
|---|
| 65 | + if (type == 'text' || type == 'voice' || type == 'image') { |
|---|
| 66 | + // Append to log immediately (sync) |
|---|
| 67 | + MessageStore.append(Message.fromMqtt(json)); |
|---|
| 68 | + |
|---|
| 69 | + // Display only if for current session |
|---|
| 70 | + if (sessionId == currentId) { |
|---|
| 71 | + _messages.add(Message.fromMqtt(json)); |
|---|
| 72 | + notifyListeners(); // or setState |
|---|
| 73 | + } else { |
|---|
| 74 | + _incrementUnread(sessionId); |
|---|
| 75 | + } |
|---|
| 76 | + } |
|---|
| 77 | +} |
|---|
| 78 | +``` |
|---|
| 79 | + |
|---|
| 80 | +No async. No switchSession during message handling. No race. |
|---|
| 81 | + |
|---|
| 82 | +### 5. Session switching |
|---|
| 83 | + |
|---|
| 84 | +```dart |
|---|
| 85 | +void switchSession(String sessionId) { |
|---|
| 86 | + _currentSessionId = sessionId; |
|---|
| 87 | + _messages = MessageStore.loadSession(sessionId); // sync read from index |
|---|
| 88 | + notifyListeners(); |
|---|
| 89 | +} |
|---|
| 90 | +``` |
|---|
| 91 | + |
|---|
| 92 | +Synchronous. No async gap. No race with incoming messages. |
|---|
| 93 | + |
|---|
| 94 | +### 6. Resume flow |
|---|
| 95 | + |
|---|
| 96 | +```dart |
|---|
| 97 | +void onResume() { |
|---|
| 98 | + state = suspended; |
|---|
| 99 | + // Kill old client (disable autoReconnect first) |
|---|
| 100 | + _client?.autoReconnect = false; |
|---|
| 101 | + _client?.disconnect(); |
|---|
| 102 | + _client = null; |
|---|
| 103 | + |
|---|
| 104 | + // Fast reconnect to last host |
|---|
| 105 | + await _fastReconnect(connectedHost); |
|---|
| 106 | + |
|---|
| 107 | + // Now process: sync → sessions → catch_up |
|---|
| 108 | + // catch_up messages go through same _onMessage path (append + display if current) |
|---|
| 109 | + state = connected; |
|---|
| 110 | +} |
|---|
| 111 | +``` |
|---|
| 112 | + |
|---|
| 113 | +### 7. Notification tap |
|---|
| 114 | + |
|---|
| 115 | +```dart |
|---|
| 116 | +void onNotificationTap(String sessionId) { |
|---|
| 117 | + if (sessionId != _currentSessionId) { |
|---|
| 118 | + switchSession(sessionId); // sync, no async |
|---|
| 119 | + } |
|---|
| 120 | + // Message is already in the log from MQTT delivery or catch_up |
|---|
| 121 | + // switchSession loads it |
|---|
| 122 | +} |
|---|
| 123 | +``` |
|---|
| 124 | + |
|---|
| 125 | +## Migration path |
|---|
| 126 | + |
|---|
| 127 | +1. Create `MessageStoreV2` with append-only log |
|---|
| 128 | +2. Create `ConnectionStateMachine` with explicit states |
|---|
| 129 | +3. Rewrite `_handleIncomingMessage` to use sync append |
|---|
| 130 | +4. Rewrite `switchSession` to be sync |
|---|
| 131 | +5. Remove debounced saves, per-session file locks, merge protection |
|---|
| 132 | +6. Test each step before moving to next |
|---|
| 133 | + |
|---|
| 134 | +## What stays the same |
|---|
| 135 | + |
|---|
| 136 | +- MQTT transport (mqtt_client package) |
|---|
| 137 | +- aedes broker with loopback client on server |
|---|
| 138 | +- Single `pailot/out` topic |
|---|
| 139 | +- APNs push notifications |
|---|
| 140 | +- Splash screen |
|---|
| 141 | +- UI components (chat bubbles, drawer, settings) |
|---|
| .. | .. |
|---|
| 69 | 69 | - Flutter |
|---|
| 70 | 70 | - FlutterMacOS |
|---|
| 71 | 71 | - SwiftyGif (5.4.5) |
|---|
| 72 | + - url_launcher_ios (0.0.1): |
|---|
| 73 | + - Flutter |
|---|
| 72 | 74 | - vibration (1.7.5): |
|---|
| 73 | 75 | - Flutter |
|---|
| 74 | 76 | |
|---|
| .. | .. |
|---|
| 88 | 90 | - record_ios (from `.symlinks/plugins/record_ios/ios`) |
|---|
| 89 | 91 | - share_plus (from `.symlinks/plugins/share_plus/ios`) |
|---|
| 90 | 92 | - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`) |
|---|
| 93 | + - url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`) |
|---|
| 91 | 94 | - vibration (from `.symlinks/plugins/vibration/ios`) |
|---|
| 92 | 95 | |
|---|
| 93 | 96 | SPEC REPOS: |
|---|
| .. | .. |
|---|
| 128 | 131 | :path: ".symlinks/plugins/share_plus/ios" |
|---|
| 129 | 132 | shared_preferences_foundation: |
|---|
| 130 | 133 | :path: ".symlinks/plugins/shared_preferences_foundation/darwin" |
|---|
| 134 | + url_launcher_ios: |
|---|
| 135 | + :path: ".symlinks/plugins/url_launcher_ios/ios" |
|---|
| 131 | 136 | vibration: |
|---|
| 132 | 137 | :path: ".symlinks/plugins/vibration/ios" |
|---|
| 133 | 138 | |
|---|
| .. | .. |
|---|
| 151 | 156 | share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a |
|---|
| 152 | 157 | shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb |
|---|
| 153 | 158 | SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 |
|---|
| 159 | + url_launcher_ios: 7a95fa5b60cc718a708b8f2966718e93db0cef1b |
|---|
| 154 | 160 | vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888 |
|---|
| 155 | 161 | |
|---|
| 156 | 162 | PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e |
|---|
| .. | .. |
|---|
| 8 | 8 | import 'providers/providers.dart'; |
|---|
| 9 | 9 | import 'services/audio_service.dart'; |
|---|
| 10 | 10 | import 'services/purchase_service.dart'; |
|---|
| 11 | +import 'screens/splash_screen.dart'; |
|---|
| 11 | 12 | |
|---|
| 12 | 13 | void main() async { |
|---|
| 13 | 14 | WidgetsFlutterBinding.ensureInitialized(); |
|---|
| .. | .. |
|---|
| 48 | 49 | |
|---|
| 49 | 50 | class _PAILotAppState extends ConsumerState<PAILotApp> { |
|---|
| 50 | 51 | late final GoRouter _router; |
|---|
| 52 | + bool _showSplash = true; |
|---|
| 51 | 53 | |
|---|
| 52 | 54 | @override |
|---|
| 53 | 55 | void initState() { |
|---|
| .. | .. |
|---|
| 78 | 80 | themeMode: themeMode, |
|---|
| 79 | 81 | routerConfig: _router, |
|---|
| 80 | 82 | debugShowCheckedModeBanner: false, |
|---|
| 83 | + builder: (context, child) { |
|---|
| 84 | + return Stack( |
|---|
| 85 | + children: [ |
|---|
| 86 | + child ?? const SizedBox.shrink(), |
|---|
| 87 | + if (_showSplash) |
|---|
| 88 | + SplashScreen(onComplete: () { |
|---|
| 89 | + if (mounted) setState(() => _showSplash = false); |
|---|
| 90 | + }), |
|---|
| 91 | + ], |
|---|
| 92 | + ); |
|---|
| 93 | + }, |
|---|
| 81 | 94 | ); |
|---|
| 82 | 95 | } |
|---|
| 83 | 96 | } |
|---|
| .. | .. |
|---|
| 9 | 9 | import '../models/server_config.dart'; |
|---|
| 10 | 10 | import '../models/session.dart'; |
|---|
| 11 | 11 | import '../services/message_store.dart'; |
|---|
| 12 | +import '../services/trace_service.dart'; |
|---|
| 12 | 13 | import '../services/mqtt_service.dart' show ConnectionStatus; |
|---|
| 13 | 14 | import '../services/navigate_notifier.dart'; |
|---|
| 14 | 15 | |
|---|
| .. | .. |
|---|
| 97 | 98 | |
|---|
| 98 | 99 | String? get currentSessionId => _currentSessionId; |
|---|
| 99 | 100 | |
|---|
| 100 | | - /// Switch to a new session and load its messages. |
|---|
| 101 | | - Future<void> switchSession(String sessionId) async { |
|---|
| 102 | | - // Force-flush current session to disk before switching |
|---|
| 103 | | - if (_currentSessionId != null && state.isNotEmpty) { |
|---|
| 104 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 105 | | - await MessageStore.flush(); |
|---|
| 101 | + /// Switch to a session. SYNCHRONOUS — no async gap, no race with incoming |
|---|
| 102 | + /// messages. MessageStoreV2.loadSession reads from the in-memory index. |
|---|
| 103 | + void switchSession(String sessionId) { |
|---|
| 104 | + if (_currentSessionId == sessionId) { |
|---|
| 105 | + TraceService.instance.addTrace( |
|---|
| 106 | + 'switchSession SKIP', 'already on ${sessionId.substring(0, 8)}'); |
|---|
| 107 | + return; |
|---|
| 106 | 108 | } |
|---|
| 107 | | - |
|---|
| 109 | + TraceService.instance.addTrace( |
|---|
| 110 | + 'switchSession', |
|---|
| 111 | + 'from=${_currentSessionId?.substring(0, 8) ?? "null"}(${state.length}) → ${sessionId.substring(0, 8)}', |
|---|
| 112 | + ); |
|---|
| 108 | 113 | _currentSessionId = sessionId; |
|---|
| 109 | | - final messages = await MessageStore.loadAll(sessionId); |
|---|
| 110 | | - state = messages; |
|---|
| 114 | + state = MessageStoreV2.loadSession(sessionId); |
|---|
| 111 | 115 | } |
|---|
| 112 | 116 | |
|---|
| 113 | | - /// Add a message to the current session. |
|---|
| 117 | + /// Add a message to the current session (display + append-only persist). |
|---|
| 114 | 118 | void addMessage(Message message) { |
|---|
| 115 | 119 | state = [...state, message]; |
|---|
| 116 | 120 | if (_currentSessionId != null) { |
|---|
| 117 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 121 | + MessageStoreV2.append(_currentSessionId!, message); |
|---|
| 118 | 122 | } |
|---|
| 119 | 123 | } |
|---|
| 120 | 124 | |
|---|
| 121 | | - /// Update a message by ID. |
|---|
| 125 | + /// Update a message by ID (in-memory only — patch is not persisted to log). |
|---|
| 122 | 126 | void updateMessage(String id, Message Function(Message) updater) { |
|---|
| 123 | 127 | state = state.map((m) => m.id == id ? updater(m) : m).toList(); |
|---|
| 124 | | - if (_currentSessionId != null) { |
|---|
| 125 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 126 | | - } |
|---|
| 127 | 128 | } |
|---|
| 128 | 129 | |
|---|
| 129 | | - /// Remove a message by ID. |
|---|
| 130 | + /// Remove a message by ID (in-memory only). |
|---|
| 130 | 131 | void removeMessage(String id) { |
|---|
| 131 | 132 | state = state.where((m) => m.id != id).toList(); |
|---|
| 132 | | - if (_currentSessionId != null) { |
|---|
| 133 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 134 | | - } |
|---|
| 135 | 133 | } |
|---|
| 136 | 134 | |
|---|
| 137 | | - /// Remove all messages matching a predicate. |
|---|
| 135 | + /// Remove all messages matching a predicate (in-memory only). |
|---|
| 138 | 136 | void removeWhere(bool Function(Message) test) { |
|---|
| 139 | 137 | state = state.where((m) => !test(m)).toList(); |
|---|
| 140 | | - if (_currentSessionId != null) { |
|---|
| 141 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 142 | | - } |
|---|
| 143 | 138 | } |
|---|
| 144 | 139 | |
|---|
| 145 | | - /// Clear all messages for the current session. |
|---|
| 140 | + /// Clear all messages for the current session (in-memory only). |
|---|
| 146 | 141 | void clearMessages() { |
|---|
| 147 | 142 | state = []; |
|---|
| 148 | | - if (_currentSessionId != null) { |
|---|
| 149 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 150 | | - } |
|---|
| 151 | 143 | } |
|---|
| 152 | 144 | |
|---|
| 153 | 145 | void updateContent(String messageId, String content) { |
|---|
| .. | .. |
|---|
| 168 | 160 | else |
|---|
| 169 | 161 | m, |
|---|
| 170 | 162 | ]; |
|---|
| 171 | | - if (_currentSessionId != null) { |
|---|
| 172 | | - MessageStore.save(_currentSessionId!, state); |
|---|
| 173 | | - } |
|---|
| 174 | | - } |
|---|
| 175 | | - |
|---|
| 176 | | - /// Load more (older) messages for pagination. |
|---|
| 177 | | - Future<void> loadMore() async { |
|---|
| 178 | | - if (_currentSessionId == null) return; |
|---|
| 179 | | - final older = await MessageStore.load( |
|---|
| 180 | | - _currentSessionId!, |
|---|
| 181 | | - offset: state.length, |
|---|
| 182 | | - limit: 50, |
|---|
| 183 | | - ); |
|---|
| 184 | | - if (older.isNotEmpty) { |
|---|
| 185 | | - state = [...older, ...state]; |
|---|
| 186 | | - } |
|---|
| 187 | 163 | } |
|---|
| 188 | 164 | } |
|---|
| 189 | 165 | |
|---|
| .. | .. |
|---|
| 65 | 65 | String? _playingMessageId; |
|---|
| 66 | 66 | int _lastSeq = 0; |
|---|
| 67 | 67 | bool _isCatchingUp = false; |
|---|
| 68 | + bool _catchUpReceived = false; |
|---|
| 68 | 69 | bool _screenshotForChat = false; |
|---|
| 69 | 70 | // FIFO dedup queue: O(1) eviction by removing from front when over cap. |
|---|
| 70 | 71 | final List<int> _seenSeqsList = []; |
|---|
| 71 | 72 | final Set<int> _seenSeqs = {}; |
|---|
| 72 | 73 | bool _sessionReady = false; |
|---|
| 73 | 74 | final List<Map<String, dynamic>> _pendingMessages = []; |
|---|
| 74 | | - final Map<String, List<Message>> _catchUpPending = {}; |
|---|
| 75 | + // _catchUpPending removed: cross-session catch_up messages are now appended |
|---|
| 76 | + // synchronously via MessageStoreV2.append() in the catch_up handler. |
|---|
| 75 | 77 | List<String>? _cachedSessionOrder; |
|---|
| 76 | 78 | Timer? _typingTimer; |
|---|
| 77 | 79 | bool _unreadCountsLoaded = false; |
|---|
| .. | .. |
|---|
| 85 | 87 | } |
|---|
| 86 | 88 | |
|---|
| 87 | 89 | Future<void> _initAll() async { |
|---|
| 90 | + // Initialize append-only message store (reads log, rebuilds index, compacts). |
|---|
| 91 | + await MessageStoreV2.initialize(); |
|---|
| 92 | + |
|---|
| 88 | 93 | // Load persisted state BEFORE connecting |
|---|
| 89 | 94 | final prefs = await SharedPreferences.getInstance(); |
|---|
| 90 | 95 | _lastSeq = prefs.getInt('lastSeq') ?? 0; |
|---|
| .. | .. |
|---|
| 103 | 108 | final savedSessionId = prefs.getString('activeSessionId'); |
|---|
| 104 | 109 | if (savedSessionId != null && mounted) { |
|---|
| 105 | 110 | ref.read(activeSessionIdProvider.notifier).state = savedSessionId; |
|---|
| 106 | | - // Load messages for the restored session so chat isn't empty on startup |
|---|
| 107 | | - await ref.read(messagesProvider.notifier).switchSession(savedSessionId); |
|---|
| 111 | + // Synchronous: no async gap between load and any arriving messages. |
|---|
| 112 | + ref.read(messagesProvider.notifier).switchSession(savedSessionId); |
|---|
| 108 | 113 | } |
|---|
| 109 | 114 | if (!mounted) return; |
|---|
| 110 | 115 | |
|---|
| .. | .. |
|---|
| 164 | 169 | _persistUnreadCounts(counts); |
|---|
| 165 | 170 | } |
|---|
| 166 | 171 | |
|---|
| 172 | + // ignore: unused_field |
|---|
| 167 | 173 | bool _isLoadingMore = false; |
|---|
| 168 | 174 | void _onScroll() { |
|---|
| 169 | | - if (!_isLoadingMore && |
|---|
| 170 | | - _scrollController.position.pixels >= |
|---|
| 171 | | - _scrollController.position.maxScrollExtent - 100) { |
|---|
| 172 | | - _isLoadingMore = true; |
|---|
| 173 | | - ref.read(messagesProvider.notifier).loadMore().then((_) => _isLoadingMore = false); |
|---|
| 174 | | - } |
|---|
| 175 | + // Pagination removed: all messages are loaded synchronously on session |
|---|
| 176 | + // switch via the in-memory index. Nothing to do on scroll. |
|---|
| 175 | 177 | } |
|---|
| 176 | 178 | |
|---|
| 177 | 179 | // Helper: send a command to the gateway in the expected format |
|---|
| .. | .. |
|---|
| 215 | 217 | _ws!.onOpen = () { |
|---|
| 216 | 218 | _sessionReady = false; // Gate messages until sessions arrive |
|---|
| 217 | 219 | _pendingMessages.clear(); |
|---|
| 218 | | - final activeId = ref.read(activeSessionIdProvider); |
|---|
| 219 | | - _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null); |
|---|
| 220 | | - // catch_up is sent after sessions arrive (in _handleSessions) |
|---|
| 221 | | - |
|---|
| 222 | | - // Re-register APNs token after reconnect so daemon always has a fresh token |
|---|
| 223 | | - _push?.onMqttConnected(); |
|---|
| 220 | + // Delay sync slightly to let broker acknowledge our subscriptions first. |
|---|
| 221 | + // Without this, the catch_up response arrives before pailot/control/out |
|---|
| 222 | + // subscription is active, and the message is lost. |
|---|
| 223 | + Future.delayed(const Duration(milliseconds: 200), () { |
|---|
| 224 | + if (!mounted) return; |
|---|
| 225 | + final activeId = ref.read(activeSessionIdProvider); |
|---|
| 226 | + _sendCommand('sync', activeId != null ? {'activeSessionId': activeId} : null); |
|---|
| 227 | + _push?.onMqttConnected(); |
|---|
| 228 | + }); |
|---|
| 224 | 229 | }; |
|---|
| 225 | 230 | _ws!.onResume = () { |
|---|
| 226 | | - // App came back from background with connection still alive. |
|---|
| 227 | | - // Send catch_up to fetch any messages missed during suspend. |
|---|
| 228 | | - _chatLog('onResume: sending catch_up with lastSeq=$_lastSeq'); |
|---|
| 231 | + // App came back from background. The in-memory state already has |
|---|
| 232 | + // any messages received while suspended (addMessage was called). |
|---|
| 233 | + // Just rebuild the UI and scroll to bottom to show them. |
|---|
| 234 | + _chatLog('onResume: rebuilding UI and sending catch_up'); |
|---|
| 229 | 235 | _sendCommand('catch_up', {'lastSeq': _lastSeq}); |
|---|
| 236 | + if (mounted) { |
|---|
| 237 | + setState(() {}); |
|---|
| 238 | + // Scroll after the frame rebuilds |
|---|
| 239 | + WidgetsBinding.instance.addPostFrameCallback((_) { |
|---|
| 240 | + if (mounted) _scrollToBottom(); |
|---|
| 241 | + }); |
|---|
| 242 | + } |
|---|
| 230 | 243 | }; |
|---|
| 231 | 244 | _ws!.onError = (error) { |
|---|
| 232 | 245 | debugPrint('MQTT error: $error'); |
|---|
| .. | .. |
|---|
| 243 | 256 | |
|---|
| 244 | 257 | await _ws!.connect(); |
|---|
| 245 | 258 | |
|---|
| 259 | + // Attach MQTT to trace service for auto-publishing logs to server |
|---|
| 260 | + TraceService.instance.attachMqtt(_ws!); |
|---|
| 261 | + |
|---|
| 246 | 262 | // Initialize push notifications after MQTT is set up so token can be |
|---|
| 247 | 263 | // sent immediately if already connected. |
|---|
| 248 | 264 | _push = PushService(mqttService: _ws!); |
|---|
| 249 | 265 | _push!.onNotificationTap = (data) { |
|---|
| 250 | | - // If notification carried a sessionId, switch to that session |
|---|
| 251 | 266 | final sessionId = data['sessionId'] as String?; |
|---|
| 252 | | - if (sessionId != null && mounted) { |
|---|
| 267 | + final activeId = ref.read(activeSessionIdProvider); |
|---|
| 268 | + // Immediately request catch_up — don't wait for the sync flow. |
|---|
| 269 | + // The message is already in the server queue. |
|---|
| 270 | + _sendCommand('catch_up', {'lastSeq': _lastSeq}); |
|---|
| 271 | + if (sessionId != null && sessionId != activeId && mounted) { |
|---|
| 253 | 272 | _switchSession(sessionId); |
|---|
| 254 | 273 | } |
|---|
| 255 | 274 | }; |
|---|
| .. | .. |
|---|
| 352 | 371 | final sessionId = msg['sessionId'] as String?; |
|---|
| 353 | 372 | if (sessionId != null) _incrementUnread(sessionId); |
|---|
| 354 | 373 | case 'catch_up': |
|---|
| 374 | + _catchUpReceived = true; |
|---|
| 355 | 375 | final serverSeq = msg['serverSeq'] as int?; |
|---|
| 356 | 376 | if (serverSeq != null) { |
|---|
| 357 | 377 | // Always sync to server's seq — if server restarted, its seq may be lower |
|---|
| 358 | 378 | _lastSeq = serverSeq; |
|---|
| 359 | 379 | _saveLastSeq(); |
|---|
| 360 | 380 | } |
|---|
| 361 | | - // Merge catch_up messages: only add messages not already in local storage. |
|---|
| 362 | | - // We check by content match against existing messages to avoid duplicates |
|---|
| 363 | | - // while still picking up messages that arrived while the app was backgrounded. |
|---|
| 381 | + // Merge catch_up messages: only add messages not already displayed. |
|---|
| 382 | + // Dedup by content to avoid showing messages already in the UI. |
|---|
| 364 | 383 | final catchUpMsgs = msg['messages'] as List<dynamic>?; |
|---|
| 365 | 384 | if (catchUpMsgs != null && catchUpMsgs.isNotEmpty) { |
|---|
| 366 | 385 | _isCatchingUp = true; |
|---|
| 367 | 386 | final activeId = ref.read(activeSessionIdProvider); |
|---|
| 387 | + final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 368 | 388 | final existing = ref.read(messagesProvider); |
|---|
| 369 | 389 | final existingContents = existing |
|---|
| 370 | 390 | .where((m) => m.role == MessageRole.assistant) |
|---|
| 371 | 391 | .map((m) => m.content) |
|---|
| 372 | 392 | .toSet(); |
|---|
| 393 | + |
|---|
| 394 | + // Collect cross-session sessions that received messages (for toasts). |
|---|
| 395 | + final crossSessionCounts = <String, int>{}; |
|---|
| 396 | + final crossSessionPreviews = <String, String>{}; |
|---|
| 397 | + |
|---|
| 373 | 398 | for (final m in catchUpMsgs) { |
|---|
| 374 | 399 | final map = m as Map<String, dynamic>; |
|---|
| 375 | 400 | final msgType = map['type'] as String? ?? 'text'; |
|---|
| .. | .. |
|---|
| 398 | 423 | ); |
|---|
| 399 | 424 | } |
|---|
| 400 | 425 | |
|---|
| 401 | | - if (msgSessionId == null || msgSessionId == activeId) { |
|---|
| 402 | | - // Active session or no session: add directly to chat |
|---|
| 426 | + _chatLog('catch_up msg: session=${msgSessionId?.substring(0, 8) ?? "NULL"} active=${activeId?.substring(0, 8)} content="${content.substring(0, content.length.clamp(0, 40))}"'); |
|---|
| 427 | + |
|---|
| 428 | + if (msgSessionId == null || msgSessionId == currentId) { |
|---|
| 429 | + // Active session or no session: add to UI (addMessage also appends to log). |
|---|
| 403 | 430 | ref.read(messagesProvider.notifier).addMessage(message); |
|---|
| 404 | 431 | } else { |
|---|
| 405 | | - // Different session: store + unread badge + toast |
|---|
| 406 | | - // Collect for batch storage below to avoid race condition |
|---|
| 407 | | - _catchUpPending.putIfAbsent(msgSessionId, () => []).add(message); |
|---|
| 432 | + // Cross-session: synchronous append — no race condition. |
|---|
| 433 | + MessageStoreV2.append(msgSessionId, message); |
|---|
| 408 | 434 | _incrementUnread(msgSessionId); |
|---|
| 435 | + crossSessionCounts[msgSessionId] = (crossSessionCounts[msgSessionId] ?? 0) + 1; |
|---|
| 436 | + crossSessionPreviews.putIfAbsent(msgSessionId, () => content); |
|---|
| 409 | 437 | } |
|---|
| 410 | 438 | existingContents.add(content); |
|---|
| 411 | 439 | } |
|---|
| 440 | + |
|---|
| 412 | 441 | _isCatchingUp = false; |
|---|
| 413 | 442 | _scrollToBottom(); |
|---|
| 414 | | - // Batch-store cross-session messages (sequential to avoid race condition) |
|---|
| 415 | | - if (_catchUpPending.isNotEmpty) { |
|---|
| 416 | | - final pending = Map<String, List<Message>>.from(_catchUpPending); |
|---|
| 417 | | - _catchUpPending.clear(); |
|---|
| 418 | | - // Show one toast per session with message count |
|---|
| 419 | | - if (mounted) { |
|---|
| 420 | | - final sessions = ref.read(sessionsProvider); |
|---|
| 421 | | - for (final entry in pending.entries) { |
|---|
| 422 | | - final session = sessions.firstWhere( |
|---|
| 423 | | - (s) => s.id == entry.key, |
|---|
| 424 | | - orElse: () => Session(id: entry.key, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 425 | | - ); |
|---|
| 426 | | - final count = entry.value.length; |
|---|
| 427 | | - final preview = count == 1 |
|---|
| 428 | | - ? entry.value.first.content |
|---|
| 429 | | - : '$count messages'; |
|---|
| 430 | | - ToastManager.show( |
|---|
| 431 | | - context, |
|---|
| 432 | | - sessionName: session.name, |
|---|
| 433 | | - preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview, |
|---|
| 434 | | - onTap: () => _switchSession(entry.key), |
|---|
| 435 | | - ); |
|---|
| 436 | | - } |
|---|
| 443 | + |
|---|
| 444 | + // Show one toast per cross-session that received messages. |
|---|
| 445 | + if (crossSessionCounts.isNotEmpty && mounted) { |
|---|
| 446 | + final sessions = ref.read(sessionsProvider); |
|---|
| 447 | + for (final entry in crossSessionCounts.entries) { |
|---|
| 448 | + final sid = entry.key; |
|---|
| 449 | + final count = entry.value; |
|---|
| 450 | + final session = sessions.firstWhere( |
|---|
| 451 | + (s) => s.id == sid, |
|---|
| 452 | + orElse: () => Session(id: sid, index: 0, name: 'Unknown', type: 'claude'), |
|---|
| 453 | + ); |
|---|
| 454 | + final preview = count == 1 |
|---|
| 455 | + ? (crossSessionPreviews[sid] ?? '') |
|---|
| 456 | + : '$count messages'; |
|---|
| 457 | + ToastManager.show( |
|---|
| 458 | + context, |
|---|
| 459 | + sessionName: session.name, |
|---|
| 460 | + preview: preview.length > 100 ? '${preview.substring(0, 100)}...' : preview, |
|---|
| 461 | + onTap: () => _switchSession(sid), |
|---|
| 462 | + ); |
|---|
| 437 | 463 | } |
|---|
| 438 | | - () async { |
|---|
| 439 | | - for (final entry in pending.entries) { |
|---|
| 440 | | - final existing = await MessageStore.loadAll(entry.key); |
|---|
| 441 | | - MessageStore.save(entry.key, [...existing, ...entry.value]); |
|---|
| 442 | | - await MessageStore.flush(); |
|---|
| 443 | | - } |
|---|
| 444 | | - }(); |
|---|
| 445 | 464 | } |
|---|
| 465 | + |
|---|
| 446 | 466 | // Clear unread for active session |
|---|
| 447 | 467 | if (activeId != null) { |
|---|
| 448 | 468 | final counts = Map<String, int>.from(ref.read(unreadCountsProvider)); |
|---|
| .. | .. |
|---|
| 480 | 500 | orElse: () => sessions.first, |
|---|
| 481 | 501 | ); |
|---|
| 482 | 502 | ref.read(activeSessionIdProvider.notifier).state = active.id; |
|---|
| 503 | + // Synchronous session switch — no async gap. |
|---|
| 483 | 504 | ref.read(messagesProvider.notifier).switchSession(active.id); |
|---|
| 484 | 505 | SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', active.id)); |
|---|
| 485 | 506 | } |
|---|
| .. | .. |
|---|
| 500 | 521 | } |
|---|
| 501 | 522 | } |
|---|
| 502 | 523 | |
|---|
| 503 | | - Future<void> _handleIncomingMessage(Map<String, dynamic> msg) async { |
|---|
| 524 | + void _handleIncomingMessage(Map<String, dynamic> msg) { |
|---|
| 504 | 525 | final sessionId = msg['sessionId'] as String?; |
|---|
| 505 | 526 | final content = msg['content'] as String? ?? |
|---|
| 506 | 527 | msg['text'] as String? ?? |
|---|
| .. | .. |
|---|
| 517 | 538 | status: MessageStatus.sent, |
|---|
| 518 | 539 | ); |
|---|
| 519 | 540 | |
|---|
| 520 | | - final activeId = ref.read(activeSessionIdProvider); |
|---|
| 521 | | - if (sessionId != null && sessionId != activeId) { |
|---|
| 522 | | - // Store message for the other session so it's there when user switches |
|---|
| 541 | + // Use currentSessionId from notifier (what's actually loaded in the provider), |
|---|
| 542 | + // not activeSessionIdProvider (can be stale after background resume). |
|---|
| 543 | + final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 544 | + if (sessionId != null && sessionId != currentId) { |
|---|
| 545 | + // Append directly to the log for the target session — synchronous, no race. |
|---|
| 523 | 546 | TraceService.instance.addTrace( |
|---|
| 524 | 547 | 'message stored for session', |
|---|
| 525 | 548 | 'sessionId=${sessionId.substring(0, sessionId.length.clamp(0, 8))}, toast shown', |
|---|
| 526 | 549 | ); |
|---|
| 527 | | - await _storeForSession(sessionId, message); |
|---|
| 550 | + MessageStoreV2.append(sessionId, message); |
|---|
| 528 | 551 | _incrementUnread(sessionId); |
|---|
| 529 | 552 | final sessions = ref.read(sessionsProvider); |
|---|
| 530 | 553 | final session = sessions.firstWhere( |
|---|
| .. | .. |
|---|
| 591 | 614 | duration: duration, |
|---|
| 592 | 615 | ); |
|---|
| 593 | 616 | |
|---|
| 594 | | - final activeId = ref.read(activeSessionIdProvider); |
|---|
| 595 | | - _chatLog('voice: sessionId=$sessionId activeId=$activeId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"'); |
|---|
| 596 | | - if (sessionId != null && sessionId != activeId) { |
|---|
| 597 | | - _chatLog('voice: cross-session, storing for $sessionId'); |
|---|
| 598 | | - await _storeForSession(sessionId, storedMessage); |
|---|
| 599 | | - _chatLog('voice: stored, incrementing unread'); |
|---|
| 617 | + final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 618 | + _chatLog('voice: sessionId=$sessionId currentId=$currentId audioPath=$savedAudioPath content="${content.substring(0, content.length.clamp(0, 30))}"'); |
|---|
| 619 | + if (sessionId != null && sessionId != currentId) { |
|---|
| 620 | + _chatLog('voice: cross-session, appending to store for $sessionId'); |
|---|
| 621 | + // Synchronous append — no async gap, no race condition. |
|---|
| 622 | + MessageStoreV2.append(sessionId, storedMessage); |
|---|
| 623 | + _chatLog('voice: appended, incrementing unread'); |
|---|
| 600 | 624 | _incrementUnread(sessionId); |
|---|
| 601 | 625 | final sessions = ref.read(sessionsProvider); |
|---|
| 602 | 626 | final session = sessions.firstWhere( |
|---|
| .. | .. |
|---|
| 662 | 686 | status: MessageStatus.sent, |
|---|
| 663 | 687 | ); |
|---|
| 664 | 688 | |
|---|
| 665 | | - // Cross-session routing: store for target session if not active |
|---|
| 666 | | - final activeId = ref.read(activeSessionIdProvider); |
|---|
| 667 | | - if (sessionId != null && sessionId != activeId) { |
|---|
| 668 | | - _storeForSession(sessionId, message); |
|---|
| 689 | + // Cross-session routing: append to log for target session if not currently loaded. |
|---|
| 690 | + final currentId = ref.read(messagesProvider.notifier).currentSessionId; |
|---|
| 691 | + if (sessionId != null && sessionId != currentId) { |
|---|
| 692 | + MessageStoreV2.append(sessionId, message); |
|---|
| 669 | 693 | _incrementUnread(sessionId); |
|---|
| 670 | 694 | return; |
|---|
| 671 | 695 | } |
|---|
| .. | .. |
|---|
| 675 | 699 | _scrollToBottom(); |
|---|
| 676 | 700 | } |
|---|
| 677 | 701 | |
|---|
| 678 | | - /// Store a message for a non-active session so it persists when the user switches to it. |
|---|
| 679 | | - Future<void> _storeForSession(String sessionId, Message message) async { |
|---|
| 680 | | - final existing = await MessageStore.loadAll(sessionId); |
|---|
| 681 | | - _chatLog('storeForSession: $sessionId existing=${existing.length} adding type=${message.type.name} content="${message.content.substring(0, message.content.length.clamp(0, 30))}" audioUri=${message.audioUri != null ? "set(${message.audioUri!.length})" : "null"}'); |
|---|
| 682 | | - MessageStore.save(sessionId, [...existing, message]); |
|---|
| 683 | | - await MessageStore.flush(); |
|---|
| 684 | | - // Verify |
|---|
| 685 | | - final verify = await MessageStore.loadAll(sessionId); |
|---|
| 686 | | - _chatLog('storeForSession: verified ${verify.length} messages after save'); |
|---|
| 702 | + /// Superseded by MessageStoreV2.append() — call sites now use the synchronous |
|---|
| 703 | + /// append directly. Kept as dead code until all callers are confirmed removed. |
|---|
| 704 | + // ignore: unused_element |
|---|
| 705 | + void _storeForSession(String sessionId, Message message) { |
|---|
| 706 | + MessageStoreV2.append(sessionId, message); |
|---|
| 687 | 707 | } |
|---|
| 688 | 708 | |
|---|
| 689 | | - /// Update a transcript for a message stored on disk (not in the active session). |
|---|
| 690 | | - /// Scans all session files to find the message by ID, updates content, and saves. |
|---|
| 709 | + /// With the append-only log, transcript updates for cross-session messages |
|---|
| 710 | + /// are not patched back to disk (the append-only design doesn't support |
|---|
| 711 | + /// in-place edits). The transcript is updated in-memory if the message is |
|---|
| 712 | + /// in the active session. Cross-session transcript updates are a no-op. |
|---|
| 691 | 713 | Future<void> _updateTranscriptOnDisk(String messageId, String content) async { |
|---|
| 692 | | - try { |
|---|
| 693 | | - final dir = await getApplicationDocumentsDirectory(); |
|---|
| 694 | | - final msgDir = Directory('${dir.path}/messages'); |
|---|
| 695 | | - if (!await msgDir.exists()) return; |
|---|
| 696 | | - |
|---|
| 697 | | - await for (final entity in msgDir.list()) { |
|---|
| 698 | | - if (entity is! File || !entity.path.endsWith('.json')) continue; |
|---|
| 699 | | - |
|---|
| 700 | | - final jsonStr = await entity.readAsString(); |
|---|
| 701 | | - final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>; |
|---|
| 702 | | - bool found = false; |
|---|
| 703 | | - |
|---|
| 704 | | - final updated = jsonList.map((j) { |
|---|
| 705 | | - final map = j as Map<String, dynamic>; |
|---|
| 706 | | - if (map['id'] == messageId) { |
|---|
| 707 | | - found = true; |
|---|
| 708 | | - return {...map, 'content': content}; |
|---|
| 709 | | - } |
|---|
| 710 | | - return map; |
|---|
| 711 | | - }).toList(); |
|---|
| 712 | | - |
|---|
| 713 | | - if (found) { |
|---|
| 714 | | - await entity.writeAsString(jsonEncode(updated)); |
|---|
| 715 | | - _chatLog('transcript: updated messageId=$messageId on disk in ${entity.path.split('/').last}'); |
|---|
| 716 | | - return; |
|---|
| 717 | | - } |
|---|
| 718 | | - } |
|---|
| 719 | | - _chatLog('transcript: messageId=$messageId not found on disk'); |
|---|
| 720 | | - } catch (e) { |
|---|
| 721 | | - _chatLog('transcript: disk update error=$e'); |
|---|
| 722 | | - } |
|---|
| 714 | + _chatLog('transcript: cross-session update for messageId=$messageId — in-memory only (append-only log)'); |
|---|
| 723 | 715 | } |
|---|
| 724 | 716 | |
|---|
| 725 | 717 | void _incrementUnread(String sessionId) { |
|---|
| .. | .. |
|---|
| 748 | 740 | ref.read(isTypingProvider.notifier).state = false; |
|---|
| 749 | 741 | |
|---|
| 750 | 742 | ref.read(activeSessionIdProvider.notifier).state = sessionId; |
|---|
| 751 | | - await ref.read(messagesProvider.notifier).switchSession(sessionId); |
|---|
| 743 | + // Synchronous — no async gap between session switch and incoming messages. |
|---|
| 744 | + ref.read(messagesProvider.notifier).switchSession(sessionId); |
|---|
| 752 | 745 | SharedPreferences.getInstance().then((p) => p.setString('activeSessionId', sessionId)); |
|---|
| 753 | 746 | |
|---|
| 754 | 747 | final counts = Map<String, int>.from(ref.read(unreadCountsProvider)); |
|---|
| .. | .. |
|---|
| 1 | +import 'dart:math' as math; |
|---|
| 2 | +import 'package:flutter/material.dart'; |
|---|
| 3 | + |
|---|
| 4 | +/// PAILot animated splash screen. |
|---|
| 5 | +/// |
|---|
| 6 | +/// Phase 1 (~0-1500ms): The P logo reveals itself progressively from top to |
|---|
| 7 | +/// bottom, as if being drawn by an invisible pen. |
|---|
| 8 | +/// |
|---|
| 9 | +/// Phase 2 (~1000-1700ms): A paper airplane flies in from the left, arcing |
|---|
| 10 | +/// across the P. |
|---|
| 11 | +/// |
|---|
| 12 | +/// After animation completes the [onComplete] callback fires. |
|---|
| 13 | +class SplashScreen extends StatefulWidget { |
|---|
| 14 | + final VoidCallback onComplete; |
|---|
| 15 | + |
|---|
| 16 | + const SplashScreen({super.key, required this.onComplete}); |
|---|
| 17 | + |
|---|
| 18 | + @override |
|---|
| 19 | + State<SplashScreen> createState() => _SplashScreenState(); |
|---|
| 20 | +} |
|---|
| 21 | + |
|---|
| 22 | +class _SplashScreenState extends State<SplashScreen> |
|---|
| 23 | + with TickerProviderStateMixin { |
|---|
| 24 | + late final AnimationController _drawController; |
|---|
| 25 | + late final AnimationController _planeController; |
|---|
| 26 | + late final AnimationController _fadeController; |
|---|
| 27 | + |
|---|
| 28 | + static const Duration _drawDuration = Duration(milliseconds: 1500); |
|---|
| 29 | + static const Duration _planeDuration = Duration(milliseconds: 700); |
|---|
| 30 | + static const Duration _fadeDuration = Duration(milliseconds: 300); |
|---|
| 31 | + |
|---|
| 32 | + @override |
|---|
| 33 | + void initState() { |
|---|
| 34 | + super.initState(); |
|---|
| 35 | + |
|---|
| 36 | + _drawController = |
|---|
| 37 | + AnimationController(vsync: this, duration: _drawDuration); |
|---|
| 38 | + _planeController = |
|---|
| 39 | + AnimationController(vsync: this, duration: _planeDuration); |
|---|
| 40 | + _fadeController = |
|---|
| 41 | + AnimationController(vsync: this, duration: _fadeDuration); |
|---|
| 42 | + |
|---|
| 43 | + _startSequence(); |
|---|
| 44 | + } |
|---|
| 45 | + |
|---|
| 46 | + Future<void> _startSequence() async { |
|---|
| 47 | + await Future.delayed(const Duration(milliseconds: 50)); |
|---|
| 48 | + if (!mounted) return; |
|---|
| 49 | + |
|---|
| 50 | + _drawController.forward(); |
|---|
| 51 | + |
|---|
| 52 | + await Future.delayed(const Duration(milliseconds: 1000)); |
|---|
| 53 | + if (!mounted) return; |
|---|
| 54 | + _planeController.forward(); |
|---|
| 55 | + |
|---|
| 56 | + await Future.delayed(const Duration(milliseconds: 800)); |
|---|
| 57 | + if (!mounted) return; |
|---|
| 58 | + |
|---|
| 59 | + await _fadeController.forward(); |
|---|
| 60 | + if (!mounted) return; |
|---|
| 61 | + widget.onComplete(); |
|---|
| 62 | + } |
|---|
| 63 | + |
|---|
| 64 | + @override |
|---|
| 65 | + void dispose() { |
|---|
| 66 | + _drawController.dispose(); |
|---|
| 67 | + _planeController.dispose(); |
|---|
| 68 | + _fadeController.dispose(); |
|---|
| 69 | + super.dispose(); |
|---|
| 70 | + } |
|---|
| 71 | + |
|---|
| 72 | + @override |
|---|
| 73 | + Widget build(BuildContext context) { |
|---|
| 74 | + return Scaffold( |
|---|
| 75 | + backgroundColor: const Color(0xFF111217), |
|---|
| 76 | + body: FadeTransition( |
|---|
| 77 | + opacity: Tween<double>(begin: 1.0, end: 0.0).animate( |
|---|
| 78 | + CurvedAnimation(parent: _fadeController, curve: Curves.easeIn), |
|---|
| 79 | + ), |
|---|
| 80 | + child: Center( |
|---|
| 81 | + child: Column( |
|---|
| 82 | + mainAxisSize: MainAxisSize.min, |
|---|
| 83 | + children: [ |
|---|
| 84 | + AnimatedBuilder( |
|---|
| 85 | + animation: |
|---|
| 86 | + Listenable.merge([_drawController, _planeController]), |
|---|
| 87 | + builder: (context, child) { |
|---|
| 88 | + return CustomPaint( |
|---|
| 89 | + size: const Size(300, 300), |
|---|
| 90 | + painter: _PLogoWithPlanePainter( |
|---|
| 91 | + drawProgress: _drawController.value, |
|---|
| 92 | + planeProgress: _planeController.value, |
|---|
| 93 | + ), |
|---|
| 94 | + ); |
|---|
| 95 | + }, |
|---|
| 96 | + ), |
|---|
| 97 | + const SizedBox(height: 24), |
|---|
| 98 | + AnimatedBuilder( |
|---|
| 99 | + animation: _drawController, |
|---|
| 100 | + builder: (context, child) { |
|---|
| 101 | + return Opacity( |
|---|
| 102 | + opacity: _drawController.value.clamp(0.0, 1.0), |
|---|
| 103 | + child: const Text( |
|---|
| 104 | + 'PAILot', |
|---|
| 105 | + style: TextStyle( |
|---|
| 106 | + color: Color(0xFF00C7FF), |
|---|
| 107 | + fontSize: 28, |
|---|
| 108 | + fontWeight: FontWeight.w300, |
|---|
| 109 | + letterSpacing: 6, |
|---|
| 110 | + ), |
|---|
| 111 | + ), |
|---|
| 112 | + ); |
|---|
| 113 | + }, |
|---|
| 114 | + ), |
|---|
| 115 | + ], |
|---|
| 116 | + ), |
|---|
| 117 | + ), |
|---|
| 118 | + ), |
|---|
| 119 | + ); |
|---|
| 120 | + } |
|---|
| 121 | +} |
|---|
| 122 | + |
|---|
| 123 | +class _PLogoWithPlanePainter extends CustomPainter { |
|---|
| 124 | + final double drawProgress; |
|---|
| 125 | + final double planeProgress; |
|---|
| 126 | + |
|---|
| 127 | + const _PLogoWithPlanePainter({ |
|---|
| 128 | + required this.drawProgress, |
|---|
| 129 | + required this.planeProgress, |
|---|
| 130 | + }); |
|---|
| 131 | + |
|---|
| 132 | + @override |
|---|
| 133 | + void paint(Canvas canvas, Size size) { |
|---|
| 134 | + const double svgW = 519.2; |
|---|
| 135 | + const double svgH = 519.3; |
|---|
| 136 | + |
|---|
| 137 | + final double scale = math.min(size.width / svgW, size.height / svgH); |
|---|
| 138 | + final double offsetX = (size.width - svgW * scale) / 2; |
|---|
| 139 | + final double offsetY = (size.height - svgH * scale) / 2; |
|---|
| 140 | + |
|---|
| 141 | + canvas.save(); |
|---|
| 142 | + canvas.translate(offsetX, offsetY); |
|---|
| 143 | + canvas.scale(scale, scale); |
|---|
| 144 | + |
|---|
| 145 | + _drawPLogo(canvas); |
|---|
| 146 | + _drawPlane(canvas); |
|---|
| 147 | + |
|---|
| 148 | + canvas.restore(); |
|---|
| 149 | + } |
|---|
| 150 | + |
|---|
| 151 | + void _drawPLogo(Canvas canvas) { |
|---|
| 152 | + if (drawProgress <= 0) return; |
|---|
| 153 | + |
|---|
| 154 | + final paths = _buildPPaths(); |
|---|
| 155 | + |
|---|
| 156 | + Rect bounds = paths.first.getBounds(); |
|---|
| 157 | + for (final p in paths.skip(1)) { |
|---|
| 158 | + bounds = bounds.expandToInclude(p.getBounds()); |
|---|
| 159 | + } |
|---|
| 160 | + |
|---|
| 161 | + final double revealY = bounds.top + |
|---|
| 162 | + bounds.height * Curves.easeInOut.transform(drawProgress); |
|---|
| 163 | + |
|---|
| 164 | + canvas.save(); |
|---|
| 165 | + canvas.clipRect(Rect.fromLTRB( |
|---|
| 166 | + bounds.left - 10, |
|---|
| 167 | + bounds.top - 10, |
|---|
| 168 | + bounds.right + 10, |
|---|
| 169 | + revealY + 6, |
|---|
| 170 | + )); |
|---|
| 171 | + |
|---|
| 172 | + final Paint gradientPaint = Paint() |
|---|
| 173 | + ..shader = const LinearGradient( |
|---|
| 174 | + begin: Alignment.topLeft, |
|---|
| 175 | + end: Alignment.bottomRight, |
|---|
| 176 | + colors: [Color(0xFF0000FF), Color(0xFF00C7FF)], |
|---|
| 177 | + ).createShader(Rect.fromLTWH(0, 0, 519.2, 519.3)) |
|---|
| 178 | + ..style = PaintingStyle.fill |
|---|
| 179 | + ..isAntiAlias = true; |
|---|
| 180 | + |
|---|
| 181 | + for (final p in paths) { |
|---|
| 182 | + canvas.drawPath(p, gradientPaint); |
|---|
| 183 | + } |
|---|
| 184 | + |
|---|
| 185 | + canvas.restore(); |
|---|
| 186 | + } |
|---|
| 187 | + |
|---|
| 188 | + List<Path> _buildPPaths() { |
|---|
| 189 | + final path1 = Path(); |
|---|
| 190 | + path1.moveTo(149.4, 68.3); |
|---|
| 191 | + path1.lineTo(191.1, 155.4); |
|---|
| 192 | + path1.lineTo(300.6, 151.2); |
|---|
| 193 | + path1.cubicTo(301.3, 151.2, 302.0, 151.1, 302.7, 151.1); |
|---|
| 194 | + path1.cubicTo(324.8, 151.1, 343.0, 169.0, 343.0, 191.4); |
|---|
| 195 | + path1.cubicTo(343.0, 201.3, 339.4, 210.4, 333.5, 217.4); |
|---|
| 196 | + path1.cubicTo(366.8, 193.1, 389.3, 153.8, 389.3, 109.5); |
|---|
| 197 | + path1.cubicTo(389.3, 69.5, 371.7, 24.8, 343.9, 0.3); |
|---|
| 198 | + path1.cubicTo(339.9, 0.0, 335.8, -0.1, 331.7, -0.1); |
|---|
| 199 | + path1.lineTo(0, -0.1); |
|---|
| 200 | + path1.lineTo(0, 5.4); |
|---|
| 201 | + path1.cubicTo(0, 81.7, 59.8, 152.7, 134.9, 156.8); |
|---|
| 202 | + path1.lineTo(107.3, 68.3); |
|---|
| 203 | + path1.close(); |
|---|
| 204 | + |
|---|
| 205 | + final path2 = Path(); |
|---|
| 206 | + path2.moveTo(518.9, 175.6); |
|---|
| 207 | + path2.cubicTo(515.9, 128.6, 495.7, 86.3, 464.4, 55.0); |
|---|
| 208 | + path2.cubicTo(433.2, 23.8, 391.1, 3.6, 344.4, 0.5); |
|---|
| 209 | + path2.cubicTo(344.3, 0.5, 344.2, 0.6, 344.3, 0.7); |
|---|
| 210 | + path2.cubicTo(372.0, 25.2, 389.5, 69.8, 389.5, 109.6); |
|---|
| 211 | + path2.cubicTo(389.5, 151.9, 364.4, 195.1, 333.7, 217.5); |
|---|
| 212 | + path2.cubicTo(327.9, 224.3, 319.9, 229.2, 310.9, 231.0); |
|---|
| 213 | + path2.cubicTo(293.9, 238.9, 275.2, 243.4, 255.9, 243.4); |
|---|
| 214 | + path2.cubicTo(274.9, 243.4, 293.3, 239.1, 310.1, 231.4); |
|---|
| 215 | + path2.cubicTo(310.2, 231.3, 310.2, 231.1, 310.0, 231.2); |
|---|
| 216 | + path2.cubicTo(307.1, 231.7, 304.0, 231.9, 300.9, 231.8); |
|---|
| 217 | + path2.lineTo(191.5, 227.6); |
|---|
| 218 | + path2.lineTo(191.4, 227.7); |
|---|
| 219 | + path2.lineTo(149.7, 314.7); |
|---|
| 220 | + path2.lineTo(149.6, 314.8); |
|---|
| 221 | + path2.lineTo(107.7, 314.8); |
|---|
| 222 | + path2.cubicTo(107.6, 314.8, 107.6, 314.7, 107.6, 314.6); |
|---|
| 223 | + path2.lineTo(135.1, 226.2); |
|---|
| 224 | + path2.cubicTo(135.1, 226.1, 135.1, 226.0, 135.0, 226.0); |
|---|
| 225 | + path2.cubicTo(59.7, 230.2, 0, 283.6, 0, 359.9); |
|---|
| 226 | + path2.lineTo(0, 375.0); |
|---|
| 227 | + path2.lineTo(0, 516.6); |
|---|
| 228 | + path2.cubicTo(0, 516.7, 0.2, 516.8, 0.2, 516.6); |
|---|
| 229 | + path2.cubicTo(29.6, 429.6, 111.9, 374.7, 208.9, 374.7); |
|---|
| 230 | + path2.lineTo(298.2, 374.7); |
|---|
| 231 | + path2.lineTo(298.4, 375.0); |
|---|
| 232 | + path2.lineTo(329.8, 375.0); |
|---|
| 233 | + path2.cubicTo(439.7, 375.0, 525.7, 285.2, 518.9, 175.6); |
|---|
| 234 | + path2.close(); |
|---|
| 235 | + |
|---|
| 236 | + final path3 = Path(); |
|---|
| 237 | + path3.moveTo(208.9, 374.5); |
|---|
| 238 | + path3.cubicTo(111.7, 374.5, 29.2, 429.6, 0, 517.0); |
|---|
| 239 | + path3.lineTo(0, 519.2); |
|---|
| 240 | + path3.lineTo(158.7, 519.2); |
|---|
| 241 | + path3.lineTo(159.0, 417.6); |
|---|
| 242 | + path3.cubicTo(158.8, 393.9, 178.0, 374.6, 201.7, 374.6); |
|---|
| 243 | + path3.lineTo(298.4, 374.6); |
|---|
| 244 | + |
|---|
| 245 | + return [path1, path2, path3]; |
|---|
| 246 | + } |
|---|
| 247 | + |
|---|
| 248 | + void _drawPlane(Canvas canvas) { |
|---|
| 249 | + if (planeProgress <= 0) return; |
|---|
| 250 | + |
|---|
| 251 | + const double startX = -80.0, startY = 290.0; |
|---|
| 252 | + const double ctrlX = 260.0, ctrlY = 100.0; |
|---|
| 253 | + const double endX = 480.0, endY = 250.0; |
|---|
| 254 | + |
|---|
| 255 | + final double t = planeProgress; |
|---|
| 256 | + final double mt = 1.0 - t; |
|---|
| 257 | + |
|---|
| 258 | + final double px = mt * mt * startX + 2 * mt * t * ctrlX + t * t * endX; |
|---|
| 259 | + final double py = mt * mt * startY + 2 * mt * t * ctrlY + t * t * endY; |
|---|
| 260 | + |
|---|
| 261 | + final double dx = 2 * mt * (ctrlX - startX) + 2 * t * (endX - ctrlX); |
|---|
| 262 | + final double dy = 2 * mt * (ctrlY - startY) + 2 * t * (endY - ctrlY); |
|---|
| 263 | + final double angle = math.atan2(dy, dx); |
|---|
| 264 | + |
|---|
| 265 | + double alpha = 1.0; |
|---|
| 266 | + if (t < 0.15) { |
|---|
| 267 | + alpha = t / 0.15; |
|---|
| 268 | + } else if (t > 0.9) { |
|---|
| 269 | + alpha = (1.0 - t) / 0.1; |
|---|
| 270 | + } |
|---|
| 271 | + |
|---|
| 272 | + canvas.save(); |
|---|
| 273 | + canvas.translate(px, py); |
|---|
| 274 | + canvas.rotate(angle); |
|---|
| 275 | + |
|---|
| 276 | + const double s = 30.0; |
|---|
| 277 | + final Paint bodyPaint = Paint() |
|---|
| 278 | + ..color = Color.fromRGBO(0, 199, 255, alpha) |
|---|
| 279 | + ..style = PaintingStyle.fill |
|---|
| 280 | + ..isAntiAlias = true; |
|---|
| 281 | + final Paint edgePaint = Paint() |
|---|
| 282 | + ..color = Color.fromRGBO(255, 255, 255, alpha * 0.6) |
|---|
| 283 | + ..style = PaintingStyle.stroke |
|---|
| 284 | + ..strokeWidth = 1.5 |
|---|
| 285 | + ..isAntiAlias = true; |
|---|
| 286 | + |
|---|
| 287 | + final Path plane = Path() |
|---|
| 288 | + ..moveTo(s, 0) |
|---|
| 289 | + ..lineTo(-s * 0.6, -s * 0.55) |
|---|
| 290 | + ..lineTo(-s * 0.2, 0) |
|---|
| 291 | + ..lineTo(-s * 0.6, s * 0.55) |
|---|
| 292 | + ..close(); |
|---|
| 293 | + |
|---|
| 294 | + canvas.drawPath(plane, bodyPaint); |
|---|
| 295 | + canvas.drawPath(plane, edgePaint); |
|---|
| 296 | + |
|---|
| 297 | + canvas.drawLine( |
|---|
| 298 | + Offset(s, 0), |
|---|
| 299 | + Offset(-s * 0.2, 0), |
|---|
| 300 | + Paint() |
|---|
| 301 | + ..color = Color.fromRGBO(255, 255, 255, alpha * 0.4) |
|---|
| 302 | + ..strokeWidth = 1.0, |
|---|
| 303 | + ); |
|---|
| 304 | + |
|---|
| 305 | + canvas.restore(); |
|---|
| 306 | + } |
|---|
| 307 | + |
|---|
| 308 | + @override |
|---|
| 309 | + bool shouldRepaint(_PLogoWithPlanePainter old) => |
|---|
| 310 | + old.drawProgress != drawProgress || old.planeProgress != planeProgress; |
|---|
| 311 | +} |
|---|
| .. | .. |
|---|
| 1 | | -import 'dart:async'; |
|---|
| 2 | 1 | import 'dart:convert'; |
|---|
| 3 | 2 | import 'dart:io'; |
|---|
| 4 | 3 | |
|---|
| .. | .. |
|---|
| 6 | 5 | import 'package:path_provider/path_provider.dart'; |
|---|
| 7 | 6 | |
|---|
| 8 | 7 | import '../models/message.dart'; |
|---|
| 8 | +import 'trace_service.dart'; |
|---|
| 9 | 9 | |
|---|
| 10 | | -/// Per-session JSON file persistence with debounced saves. |
|---|
| 11 | | -class MessageStore { |
|---|
| 12 | | - MessageStore._(); |
|---|
| 10 | +/// Append-only log-based message persistence. |
|---|
| 11 | +/// |
|---|
| 12 | +/// Layout: |
|---|
| 13 | +/// messages/log.jsonl — one JSON object per line, each a serialized Message |
|---|
| 14 | +/// messages/index.json — { "sessionId": [lineNumber, ...] } |
|---|
| 15 | +/// |
|---|
| 16 | +/// All writes are synchronous (writeAsStringSync with FileMode.append) to |
|---|
| 17 | +/// prevent race conditions between concurrent addMessage / switchSession calls. |
|---|
| 18 | +class MessageStoreV2 { |
|---|
| 19 | + MessageStoreV2._(); |
|---|
| 20 | + |
|---|
| 21 | + static const _backupChannel = MethodChannel('com.mnsoft.pailot/backup'); |
|---|
| 22 | + |
|---|
| 23 | + // In-memory index: sessionId -> list of 0-based line numbers in log.jsonl |
|---|
| 24 | + static final Map<String, List<int>> _index = {}; |
|---|
| 25 | + |
|---|
| 26 | + // Number of lines currently in the log (= next line number to write) |
|---|
| 27 | + static int _lineCount = 0; |
|---|
| 28 | + |
|---|
| 29 | + // Flush the index to disk every N appends to amortise I/O |
|---|
| 30 | + static const _indexFlushInterval = 20; |
|---|
| 31 | + static int _appendsSinceFlush = 0; |
|---|
| 13 | 32 | |
|---|
| 14 | 33 | static Directory? _baseDir; |
|---|
| 15 | | - static Timer? _debounceTimer; |
|---|
| 16 | | - static final Map<String, List<Message>> _pendingSaves = {}; |
|---|
| 17 | 34 | |
|---|
| 18 | | - static const _backupChannel = |
|---|
| 19 | | - MethodChannel('com.mnsoft.pailot/backup'); |
|---|
| 35 | + // ------------------------------------------------------------------ init -- |
|---|
| 20 | 36 | |
|---|
| 21 | | - /// Initialize the base directory for message storage. |
|---|
| 22 | | - /// On iOS, the directory is excluded from iCloud / iTunes backup so that |
|---|
| 23 | | - /// large base64 image attachments do not bloat the user's cloud storage. |
|---|
| 24 | | - /// Messages can be re-fetched from the server if needed. |
|---|
| 25 | 37 | static Future<Directory> _getBaseDir() async { |
|---|
| 26 | 38 | if (_baseDir != null) return _baseDir!; |
|---|
| 27 | 39 | final appDir = await getApplicationDocumentsDirectory(); |
|---|
| 28 | 40 | _baseDir = Directory('${appDir.path}/messages'); |
|---|
| 29 | | - final created = !await _baseDir!.exists(); |
|---|
| 30 | | - if (created) { |
|---|
| 31 | | - await _baseDir!.create(recursive: true); |
|---|
| 41 | + if (!_baseDir!.existsSync()) { |
|---|
| 42 | + _baseDir!.createSync(recursive: true); |
|---|
| 32 | 43 | } |
|---|
| 33 | 44 | // Exclude from iCloud / iTunes backup (best-effort, iOS only). |
|---|
| 34 | 45 | if (Platform.isIOS) { |
|---|
| .. | .. |
|---|
| 37 | 48 | 'excludeFromBackup', |
|---|
| 38 | 49 | _baseDir!.path, |
|---|
| 39 | 50 | ); |
|---|
| 40 | | - } catch (_) { |
|---|
| 41 | | - // Non-fatal: if the channel call fails, backup exclusion is skipped. |
|---|
| 42 | | - } |
|---|
| 51 | + } catch (_) {} |
|---|
| 43 | 52 | } |
|---|
| 44 | 53 | return _baseDir!; |
|---|
| 45 | 54 | } |
|---|
| 46 | 55 | |
|---|
| 47 | | - static String _fileForSession(String sessionId) { |
|---|
| 48 | | - // Sanitize session ID for filename |
|---|
| 49 | | - final safe = sessionId.replaceAll(RegExp(r'[^\w\-]'), '_'); |
|---|
| 50 | | - return 'session_$safe.json'; |
|---|
| 51 | | - } |
|---|
| 56 | + static String _logPath(Directory dir) => '${dir.path}/log.jsonl'; |
|---|
| 57 | + static String _indexPath(Directory dir) => '${dir.path}/index.json'; |
|---|
| 52 | 58 | |
|---|
| 53 | | - /// Save messages for a session with 1-second debounce. |
|---|
| 54 | | - static void save(String sessionId, List<Message> messages) { |
|---|
| 55 | | - _pendingSaves[sessionId] = messages; |
|---|
| 56 | | - _debounceTimer?.cancel(); |
|---|
| 57 | | - _debounceTimer = Timer(const Duration(seconds: 1), _flushAll); |
|---|
| 58 | | - } |
|---|
| 59 | + /// Called once at app startup. Reads log.jsonl and rebuilds the in-memory |
|---|
| 60 | + /// index. Then calls compact() to trim old messages. |
|---|
| 61 | + static Future<void> initialize() async { |
|---|
| 62 | + try { |
|---|
| 63 | + final dir = await _getBaseDir(); |
|---|
| 64 | + final logFile = File(_logPath(dir)); |
|---|
| 65 | + final indexFile = File(_indexPath(dir)); |
|---|
| 59 | 66 | |
|---|
| 60 | | - /// Immediately flush all pending saves. |
|---|
| 61 | | - static Future<void> flush() async { |
|---|
| 62 | | - _debounceTimer?.cancel(); |
|---|
| 63 | | - await _flushAll(); |
|---|
| 64 | | - } |
|---|
| 67 | + // Always rebuild index from log (the saved index.json may be stale |
|---|
| 68 | + // if the app was killed before a flush). |
|---|
| 69 | + if (logFile.existsSync()) { |
|---|
| 70 | + final content = logFile.readAsStringSync(); |
|---|
| 71 | + _lineCount = content.isEmpty |
|---|
| 72 | + ? 0 |
|---|
| 73 | + : content.trimRight().split('\n').length; |
|---|
| 74 | + if (_lineCount > 0) { |
|---|
| 75 | + await _rebuildIndex(logFile); |
|---|
| 76 | + } |
|---|
| 77 | + } else { |
|---|
| 78 | + _lineCount = 0; |
|---|
| 79 | + } |
|---|
| 65 | 80 | |
|---|
| 66 | | - static Future<void> _flushAll() async { |
|---|
| 67 | | - final entries = Map<String, List<Message>>.from(_pendingSaves); |
|---|
| 68 | | - _pendingSaves.clear(); |
|---|
| 81 | + TraceService.instance.addTrace( |
|---|
| 82 | + 'MsgStoreV2 INIT', '$_lineCount lines, ${_index.length} sessions'); |
|---|
| 69 | 83 | |
|---|
| 70 | | - for (final entry in entries.entries) { |
|---|
| 71 | | - await _writeSession(entry.key, entry.value); |
|---|
| 84 | + // Compact on startup (keeps last 200 per session). |
|---|
| 85 | + await compact(); |
|---|
| 86 | + } catch (e) { |
|---|
| 87 | + TraceService.instance.addTrace('MsgStoreV2 INIT ERROR', '$e'); |
|---|
| 72 | 88 | } |
|---|
| 73 | 89 | } |
|---|
| 74 | 90 | |
|---|
| 75 | | - static Future<void> _writeSession( |
|---|
| 76 | | - String sessionId, List<Message> messages) async { |
|---|
| 77 | | - try { |
|---|
| 78 | | - final dir = await _getBaseDir(); |
|---|
| 79 | | - final file = File('${dir.path}/${_fileForSession(sessionId)}'); |
|---|
| 80 | | - // Strip heavy fields for persistence |
|---|
| 81 | | - final lightMessages = messages.map((m) => m.toJsonLight()).toList(); |
|---|
| 82 | | - await file.writeAsString(jsonEncode(lightMessages)); |
|---|
| 83 | | - } catch (e) { |
|---|
| 84 | | - // Silently fail - message persistence is best-effort |
|---|
| 91 | + static Future<void> _rebuildIndex(File logFile) async { |
|---|
| 92 | + _index.clear(); |
|---|
| 93 | + final lines = logFile.readAsLinesSync(); |
|---|
| 94 | + for (var i = 0; i < lines.length; i++) { |
|---|
| 95 | + final line = lines[i].trim(); |
|---|
| 96 | + if (line.isEmpty) continue; |
|---|
| 97 | + try { |
|---|
| 98 | + final map = jsonDecode(line) as Map<String, dynamic>; |
|---|
| 99 | + final sessionId = map['sessionId'] as String?; |
|---|
| 100 | + if (sessionId != null) { |
|---|
| 101 | + _index.putIfAbsent(sessionId, () => []).add(i); |
|---|
| 102 | + } |
|---|
| 103 | + } catch (_) {} |
|---|
| 85 | 104 | } |
|---|
| 86 | 105 | } |
|---|
| 87 | 106 | |
|---|
| 88 | | - /// Load messages for a session. |
|---|
| 89 | | - /// [limit] controls how many recent messages to return (default: 50). |
|---|
| 90 | | - /// [offset] is the number of messages to skip from the end (for pagination). |
|---|
| 91 | | - static Future<List<Message>> load( |
|---|
| 92 | | - String sessionId, { |
|---|
| 93 | | - int limit = 50, |
|---|
| 94 | | - int offset = 0, |
|---|
| 95 | | - }) async { |
|---|
| 107 | + // --------------------------------------------------------------- append -- |
|---|
| 108 | + |
|---|
| 109 | + /// Append a message to the log. SYNCHRONOUS — no async gap, no race. |
|---|
| 110 | + /// |
|---|
| 111 | + /// Each line written includes a 'sessionId' field so the index can be |
|---|
| 112 | + /// rebuilt from the log alone if needed. |
|---|
| 113 | + static void append(String sessionId, Message message) { |
|---|
| 96 | 114 | try { |
|---|
| 97 | | - final dir = await _getBaseDir(); |
|---|
| 98 | | - final file = File('${dir.path}/${_fileForSession(sessionId)}'); |
|---|
| 99 | | - if (!await file.exists()) return []; |
|---|
| 115 | + final dir = _baseDir; |
|---|
| 116 | + if (dir == null) { |
|---|
| 117 | + // initialize() hasn't been called yet — silently drop (shouldn't happen). |
|---|
| 118 | + TraceService.instance |
|---|
| 119 | + .addTrace('MsgStoreV2 APPEND WARN', 'baseDir null, dropping'); |
|---|
| 120 | + return; |
|---|
| 121 | + } |
|---|
| 122 | + final logFile = File(_logPath(dir)); |
|---|
| 123 | + final json = message.toJsonLight(); |
|---|
| 124 | + json['sessionId'] = sessionId; |
|---|
| 125 | + final line = '${jsonEncode(json)}\n'; |
|---|
| 100 | 126 | |
|---|
| 101 | | - final jsonStr = await file.readAsString(); |
|---|
| 102 | | - final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>; |
|---|
| 103 | | - final allMessages = jsonList |
|---|
| 104 | | - .map((j) => _messageFromJson(j as Map<String, dynamic>)) |
|---|
| 105 | | - .where((m) => !m.isEmptyVoice && !m.isEmptyText) |
|---|
| 106 | | - .toList(); |
|---|
| 127 | + // Synchronous append — atomic single write, no read-modify-write. |
|---|
| 128 | + logFile.writeAsStringSync(line, mode: FileMode.append); |
|---|
| 107 | 129 | |
|---|
| 108 | | - // Paginate from the end (newest messages first in storage) |
|---|
| 109 | | - if (offset >= allMessages.length) return []; |
|---|
| 110 | | - final end = allMessages.length - offset; |
|---|
| 111 | | - final start = (end - limit).clamp(0, end); |
|---|
| 112 | | - return allMessages.sublist(start, end); |
|---|
| 130 | + // Update in-memory index. |
|---|
| 131 | + final lineNum = _lineCount; |
|---|
| 132 | + _index.putIfAbsent(sessionId, () => []).add(lineNum); |
|---|
| 133 | + _lineCount++; |
|---|
| 134 | + |
|---|
| 135 | + TraceService.instance.addTrace('MsgStoreV2 APPEND', |
|---|
| 136 | + '${sessionId.substring(0, 8)} line=$lineNum total=$_lineCount idx=${_index[sessionId]?.length ?? 0}'); |
|---|
| 137 | + |
|---|
| 138 | + // Flush index after every append to prevent data loss on app kill. |
|---|
| 139 | + _flushIndex(dir); |
|---|
| 113 | 140 | } catch (e) { |
|---|
| 141 | + TraceService.instance.addTrace('MsgStoreV2 APPEND ERROR', '$e'); |
|---|
| 142 | + } |
|---|
| 143 | + } |
|---|
| 144 | + |
|---|
| 145 | + // -------------------------------------------------------------- load -- |
|---|
| 146 | + |
|---|
| 147 | + /// Load messages for a session. SYNCHRONOUS — reads from the log using the |
|---|
| 148 | + /// in-memory index. Safe to call from switchSession without async gaps. |
|---|
| 149 | + static List<Message> loadSession(String sessionId) { |
|---|
| 150 | + try { |
|---|
| 151 | + final dir = _baseDir; |
|---|
| 152 | + if (dir == null) return []; |
|---|
| 153 | + final logFile = File(_logPath(dir)); |
|---|
| 154 | + if (!logFile.existsSync()) return []; |
|---|
| 155 | + |
|---|
| 156 | + final lineNumbers = _index[sessionId]; |
|---|
| 157 | + if (lineNumbers == null || lineNumbers.isEmpty) return []; |
|---|
| 158 | + |
|---|
| 159 | + // Read all lines at once then pick the ones we need. |
|---|
| 160 | + final allLines = logFile.readAsLinesSync(); |
|---|
| 161 | + TraceService.instance.addTrace('MsgStoreV2 LOAD detail', |
|---|
| 162 | + '${sessionId.substring(0, 8)}: fileLines=${allLines.length} indexEntries=${lineNumbers.length} lineCount=$_lineCount'); |
|---|
| 163 | + final messages = <Message>[]; |
|---|
| 164 | + |
|---|
| 165 | + for (final n in lineNumbers) { |
|---|
| 166 | + if (n >= allLines.length) continue; |
|---|
| 167 | + final line = allLines[n].trim(); |
|---|
| 168 | + if (line.isEmpty) continue; |
|---|
| 169 | + try { |
|---|
| 170 | + final map = jsonDecode(line) as Map<String, dynamic>; |
|---|
| 171 | + // Remove synthetic sessionId field before deserialising. |
|---|
| 172 | + map.remove('sessionId'); |
|---|
| 173 | + final msg = _messageFromJson(map); |
|---|
| 174 | + if (!msg.isEmptyVoice && !msg.isEmptyText) { |
|---|
| 175 | + messages.add(msg); |
|---|
| 176 | + } |
|---|
| 177 | + } catch (_) {} |
|---|
| 178 | + } |
|---|
| 179 | + |
|---|
| 180 | + TraceService.instance.addTrace( |
|---|
| 181 | + 'MsgStoreV2 LOAD', '${sessionId.substring(0, 8)}: ${messages.length} msgs'); |
|---|
| 182 | + return messages; |
|---|
| 183 | + } catch (e) { |
|---|
| 184 | + TraceService.instance |
|---|
| 185 | + .addTrace('MsgStoreV2 LOAD ERROR', '${sessionId.substring(0, 8)}: $e'); |
|---|
| 114 | 186 | return []; |
|---|
| 115 | 187 | } |
|---|
| 116 | 188 | } |
|---|
| 117 | 189 | |
|---|
| 118 | | - /// Load all messages for a session (no pagination). |
|---|
| 119 | | - static Future<List<Message>> loadAll(String sessionId) async { |
|---|
| 190 | + // ------------------------------------------------------------- compact -- |
|---|
| 191 | + |
|---|
| 192 | + /// Rewrite the log keeping at most [keepPerSession] messages per session. |
|---|
| 193 | + /// Called once on startup after initialize(). NOT called during normal use. |
|---|
| 194 | + static Future<void> compact({int keepPerSession = 200}) async { |
|---|
| 120 | 195 | try { |
|---|
| 121 | 196 | final dir = await _getBaseDir(); |
|---|
| 122 | | - final file = File('${dir.path}/${_fileForSession(sessionId)}'); |
|---|
| 123 | | - if (!await file.exists()) return []; |
|---|
| 197 | + final logFile = File(_logPath(dir)); |
|---|
| 198 | + if (!logFile.existsSync()) return; |
|---|
| 124 | 199 | |
|---|
| 125 | | - final jsonStr = await file.readAsString(); |
|---|
| 126 | | - final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>; |
|---|
| 127 | | - return jsonList |
|---|
| 128 | | - .map((j) => _messageFromJson(j as Map<String, dynamic>)) |
|---|
| 129 | | - .where((m) => !m.isEmptyVoice && !m.isEmptyText) |
|---|
| 130 | | - .toList(); |
|---|
| 200 | + final allLines = logFile.readAsLinesSync(); |
|---|
| 201 | + if (allLines.length < 500) return; // nothing worth compacting |
|---|
| 202 | + |
|---|
| 203 | + // Build a set of line numbers to keep: last keepPerSession per session. |
|---|
| 204 | + final keepLines = <int>{}; |
|---|
| 205 | + for (final entry in _index.entries) { |
|---|
| 206 | + final lines = entry.value; |
|---|
| 207 | + final start = lines.length > keepPerSession |
|---|
| 208 | + ? lines.length - keepPerSession |
|---|
| 209 | + : 0; |
|---|
| 210 | + for (var i = start; i < lines.length; i++) { |
|---|
| 211 | + keepLines.add(lines[i]); |
|---|
| 212 | + } |
|---|
| 213 | + } |
|---|
| 214 | + |
|---|
| 215 | + if (keepLines.length == allLines.length) return; // nothing removed |
|---|
| 216 | + |
|---|
| 217 | + // Rewrite the log with only the kept lines, rebuilding the index. |
|---|
| 218 | + final newIndex = <String, List<int>>{}; |
|---|
| 219 | + final buffer = StringBuffer(); |
|---|
| 220 | + var newLine = 0; |
|---|
| 221 | + |
|---|
| 222 | + for (var i = 0; i < allLines.length; i++) { |
|---|
| 223 | + if (!keepLines.contains(i)) continue; |
|---|
| 224 | + final line = allLines[i].trim(); |
|---|
| 225 | + if (line.isEmpty) continue; |
|---|
| 226 | + buffer.write('$line\n'); |
|---|
| 227 | + // Extract sessionId for new index. |
|---|
| 228 | + try { |
|---|
| 229 | + final map = jsonDecode(line) as Map<String, dynamic>; |
|---|
| 230 | + final sid = map['sessionId'] as String?; |
|---|
| 231 | + if (sid != null) { |
|---|
| 232 | + newIndex.putIfAbsent(sid, () => []).add(newLine); |
|---|
| 233 | + } |
|---|
| 234 | + } catch (_) {} |
|---|
| 235 | + newLine++; |
|---|
| 236 | + } |
|---|
| 237 | + |
|---|
| 238 | + logFile.writeAsStringSync(buffer.toString()); |
|---|
| 239 | + _index |
|---|
| 240 | + ..clear() |
|---|
| 241 | + ..addAll(newIndex); |
|---|
| 242 | + _lineCount = newLine; |
|---|
| 243 | + _flushIndex(dir); |
|---|
| 244 | + |
|---|
| 245 | + TraceService.instance.addTrace( |
|---|
| 246 | + 'MsgStoreV2 COMPACT', '${allLines.length} → $newLine lines'); |
|---|
| 131 | 247 | } catch (e) { |
|---|
| 132 | | - return []; |
|---|
| 248 | + TraceService.instance.addTrace('MsgStoreV2 COMPACT ERROR', '$e'); |
|---|
| 133 | 249 | } |
|---|
| 134 | 250 | } |
|---|
| 135 | 251 | |
|---|
| 136 | | - /// Deserialize a message from JSON, applying migration rules: |
|---|
| 137 | | - /// - Voice messages without audioUri are downgraded to text (transcript only). |
|---|
| 138 | | - /// This handles messages saved before a restart, where the temp audio file |
|---|
| 139 | | - /// is no longer available. The transcript (content) is preserved. |
|---|
| 252 | + // --------------------------------------------------------- index flush -- |
|---|
| 253 | + |
|---|
| 254 | + static void _flushIndex(Directory dir) { |
|---|
| 255 | + try { |
|---|
| 256 | + final indexMap = _index.map( |
|---|
| 257 | + (k, v) => MapEntry(k, v)); |
|---|
| 258 | + File(_indexPath(dir)) |
|---|
| 259 | + .writeAsStringSync(jsonEncode(indexMap)); |
|---|
| 260 | + } catch (_) {} |
|---|
| 261 | + } |
|---|
| 262 | + |
|---|
| 263 | + /// Force-flush the index to disk (call on app suspend / session switch). |
|---|
| 264 | + static void flushIndex() { |
|---|
| 265 | + if (_baseDir != null) _flushIndex(_baseDir!); |
|---|
| 266 | + } |
|---|
| 267 | + |
|---|
| 268 | + // ------------------------------------------------- migration helper -- |
|---|
| 269 | + |
|---|
| 270 | + /// Deserialize a message, downgrading voice→text if audio is unavailable. |
|---|
| 140 | 271 | static Message _messageFromJson(Map<String, dynamic> json) { |
|---|
| 141 | 272 | final raw = Message.fromJson(json); |
|---|
| 142 | 273 | if (raw.type == MessageType.voice && |
|---|
| 143 | 274 | (raw.audioUri == null || raw.audioUri!.isEmpty)) { |
|---|
| 144 | | - // Downgrade to text so the bubble shows the transcript instead of a |
|---|
| 145 | | - // broken play button. |
|---|
| 146 | 275 | return Message( |
|---|
| 147 | 276 | id: raw.id, |
|---|
| 148 | 277 | role: raw.role, |
|---|
| .. | .. |
|---|
| 156 | 285 | return raw; |
|---|
| 157 | 286 | } |
|---|
| 158 | 287 | |
|---|
| 159 | | - /// Delete stored messages for a session. |
|---|
| 160 | | - static Future<void> delete(String sessionId) async { |
|---|
| 161 | | - try { |
|---|
| 162 | | - final dir = await _getBaseDir(); |
|---|
| 163 | | - final file = File('${dir.path}/${_fileForSession(sessionId)}'); |
|---|
| 164 | | - if (await file.exists()) { |
|---|
| 165 | | - await file.delete(); |
|---|
| 166 | | - } |
|---|
| 167 | | - } catch (_) {} |
|---|
| 168 | | - } |
|---|
| 288 | + // --------------------------------------------------------- clear all -- |
|---|
| 169 | 289 | |
|---|
| 170 | | - /// Clear all stored messages. |
|---|
| 290 | + /// Wipe everything (used from settings / debug). |
|---|
| 171 | 291 | static Future<void> clearAll() async { |
|---|
| 172 | 292 | try { |
|---|
| 173 | 293 | final dir = await _getBaseDir(); |
|---|
| 174 | | - if (await dir.exists()) { |
|---|
| 175 | | - await dir.delete(recursive: true); |
|---|
| 176 | | - await dir.create(recursive: true); |
|---|
| 294 | + if (dir.existsSync()) { |
|---|
| 295 | + dir.deleteSync(recursive: true); |
|---|
| 296 | + dir.createSync(recursive: true); |
|---|
| 177 | 297 | } |
|---|
| 298 | + _index.clear(); |
|---|
| 299 | + _lineCount = 0; |
|---|
| 178 | 300 | } catch (_) {} |
|---|
| 179 | 301 | } |
|---|
| 180 | 302 | } |
|---|
| .. | .. |
|---|
| 11 | 11 | import 'package:path_provider/path_provider.dart' as pp; |
|---|
| 12 | 12 | import 'package:mqtt_client/mqtt_client.dart'; |
|---|
| 13 | 13 | import 'package:mqtt_client/mqtt_server_client.dart'; |
|---|
| 14 | +import 'package:typed_data/typed_data.dart'; |
|---|
| 14 | 15 | import 'package:shared_preferences/shared_preferences.dart'; |
|---|
| 15 | 16 | import 'package:uuid/uuid.dart'; |
|---|
| 16 | 17 | |
|---|
| .. | .. |
|---|
| 46 | 47 | /// Subscribes to all pailot/ topics and dispatches messages |
|---|
| 47 | 48 | /// through the onMessage callback interface. |
|---|
| 48 | 49 | class MqttService with WidgetsBindingObserver { |
|---|
| 49 | | - MqttService({required this.config}); |
|---|
| 50 | + MqttService({required this.config}) { |
|---|
| 51 | + WidgetsBinding.instance.addObserver(this); |
|---|
| 52 | + } |
|---|
| 50 | 53 | |
|---|
| 51 | 54 | ServerConfig config; |
|---|
| 52 | 55 | MqttServerClient? _client; |
|---|
| .. | .. |
|---|
| 61 | 64 | // Message deduplication |
|---|
| 62 | 65 | final Set<String> _seenMsgIds = {}; |
|---|
| 63 | 66 | final List<String> _seenMsgIdOrder = []; |
|---|
| 67 | + |
|---|
| 68 | + // (Per-session subscriptions removed — single pailot/out topic now) |
|---|
| 64 | 69 | static const int _maxSeenIds = 500; |
|---|
| 65 | 70 | |
|---|
| 66 | 71 | // Callbacks |
|---|
| .. | .. |
|---|
| 97 | 102 | } |
|---|
| 98 | 103 | _clientId = id; |
|---|
| 99 | 104 | return id; |
|---|
| 105 | + } |
|---|
| 106 | + |
|---|
| 107 | + /// Force reconnect — disconnect and reconnect to last known host. |
|---|
| 108 | + void forceReconnect() { |
|---|
| 109 | + _mqttLog('MQTT: force reconnect requested'); |
|---|
| 110 | + final lastHost = connectedHost; |
|---|
| 111 | + _client?.disconnect(); |
|---|
| 112 | + _client = null; |
|---|
| 113 | + _setStatus(ConnectionStatus.reconnecting); |
|---|
| 114 | + onReconnecting?.call(); |
|---|
| 115 | + if (lastHost != null) { |
|---|
| 116 | + _fastReconnect(lastHost); |
|---|
| 117 | + } else { |
|---|
| 118 | + connect(); |
|---|
| 119 | + } |
|---|
| 120 | + } |
|---|
| 121 | + |
|---|
| 122 | + /// Fast reconnect to a known host — skips discovery, short timeout. |
|---|
| 123 | + Future<void> _fastReconnect(String host) async { |
|---|
| 124 | + _mqttLog('MQTT: fast reconnect to $host'); |
|---|
| 125 | + final clientId = await _getClientId(); |
|---|
| 126 | + if (await _tryConnect(host, clientId, timeout: 2000)) { |
|---|
| 127 | + connectedHost = host; |
|---|
| 128 | + return; |
|---|
| 129 | + } |
|---|
| 130 | + // Fast path failed — fall back to full connect |
|---|
| 131 | + _mqttLog('MQTT: fast reconnect failed, full connect...'); |
|---|
| 132 | + connect(); |
|---|
| 100 | 133 | } |
|---|
| 101 | 134 | |
|---|
| 102 | 135 | /// Connect to the MQTT broker. |
|---|
| .. | .. |
|---|
| 364 | 397 | Future<bool> _tryConnect(String host, String clientId, {int timeout = 5000}) async { |
|---|
| 365 | 398 | try { |
|---|
| 366 | 399 | final client = MqttServerClient.withPort(host, clientId, config.port); |
|---|
| 367 | | - client.keepAlivePeriod = 30; |
|---|
| 400 | + client.keepAlivePeriod = 120; // 2 min — iOS throttles bg network, short keepalive causes drops |
|---|
| 368 | 401 | client.autoReconnect = false; // Don't auto-reconnect during trial — enable after success |
|---|
| 369 | 402 | client.connectTimeoutPeriod = timeout; |
|---|
| 370 | 403 | // client.maxConnectionAttempts is final — can't set it |
|---|
| .. | .. |
|---|
| 407 | 440 | ); |
|---|
| 408 | 441 | _mqttLog('MQTT: connect result=${result?.state}'); |
|---|
| 409 | 442 | if (result?.state == MqttConnectionState.connected) { |
|---|
| 410 | | - client.autoReconnect = true; // Now enable auto-reconnect for the live connection |
|---|
| 443 | + client.autoReconnect = true; |
|---|
| 411 | 444 | return true; |
|---|
| 412 | 445 | } |
|---|
| 413 | 446 | _client = null; |
|---|
| .. | .. |
|---|
| 458 | 491 | _mqttLog('MQTT: _subscribe called but client is null'); |
|---|
| 459 | 492 | return; |
|---|
| 460 | 493 | } |
|---|
| 494 | + // Single outbound topic — all messages carry sessionId in payload. |
|---|
| 495 | + // Client routes messages to the correct session based on payload. |
|---|
| 461 | 496 | _mqttLog('MQTT: subscribing to topics...'); |
|---|
| 497 | + client.subscribe('pailot/out', MqttQos.atLeastOnce); |
|---|
| 462 | 498 | client.subscribe('pailot/sessions', MqttQos.atLeastOnce); |
|---|
| 463 | 499 | client.subscribe('pailot/status', MqttQos.atLeastOnce); |
|---|
| 464 | 500 | client.subscribe('pailot/projects', MqttQos.atLeastOnce); |
|---|
| 465 | | - client.subscribe('pailot/+/out', MqttQos.atLeastOnce); |
|---|
| 466 | | - client.subscribe('pailot/+/typing', MqttQos.atMostOnce); |
|---|
| 467 | | - client.subscribe('pailot/+/screenshot', MqttQos.atLeastOnce); |
|---|
| 468 | 501 | client.subscribe('pailot/control/out', MqttQos.atLeastOnce); |
|---|
| 469 | | - client.subscribe('pailot/voice/transcript', MqttQos.atLeastOnce); |
|---|
| 470 | 502 | } |
|---|
| 471 | 503 | |
|---|
| 472 | 504 | void _listenMessages() { |
|---|
| .. | .. |
|---|
| 523 | 555 | /// Translates MQTT topic structure into the flat message format |
|---|
| 524 | 556 | /// that chat_screen expects. |
|---|
| 525 | 557 | void _dispatchMessage(String topic, Map<String, dynamic> json) { |
|---|
| 526 | | - final parts = topic.split('/'); |
|---|
| 527 | | - |
|---|
| 528 | 558 | // pailot/sessions |
|---|
| 529 | 559 | if (topic == 'pailot/sessions') { |
|---|
| 530 | 560 | json['type'] = 'sessions'; |
|---|
| .. | .. |
|---|
| 546 | 576 | return; |
|---|
| 547 | 577 | } |
|---|
| 548 | 578 | |
|---|
| 549 | | - // pailot/control/out — command responses (session_switched, session_renamed, error, unread) |
|---|
| 579 | + // pailot/control/out — command responses |
|---|
| 550 | 580 | if (topic == 'pailot/control/out') { |
|---|
| 551 | 581 | onMessage?.call(json); |
|---|
| 552 | 582 | return; |
|---|
| 553 | 583 | } |
|---|
| 554 | 584 | |
|---|
| 555 | | - // pailot/voice/transcript |
|---|
| 556 | | - if (topic == 'pailot/voice/transcript') { |
|---|
| 557 | | - json['type'] = 'transcript'; |
|---|
| 558 | | - onMessage?.call(json); |
|---|
| 559 | | - return; |
|---|
| 560 | | - } |
|---|
| 561 | | - |
|---|
| 562 | | - // pailot/<sessionId>/out — text, voice, image messages |
|---|
| 563 | | - if (parts.length == 3 && parts[2] == 'out') { |
|---|
| 564 | | - final sessionId = parts[1]; |
|---|
| 565 | | - json['sessionId'] ??= sessionId; |
|---|
| 566 | | - onMessage?.call(json); |
|---|
| 567 | | - return; |
|---|
| 568 | | - } |
|---|
| 569 | | - |
|---|
| 570 | | - // pailot/<sessionId>/typing |
|---|
| 571 | | - if (parts.length == 3 && parts[2] == 'typing') { |
|---|
| 572 | | - final sessionId = parts[1]; |
|---|
| 573 | | - json['type'] = 'typing'; |
|---|
| 574 | | - json['sessionId'] ??= sessionId; |
|---|
| 575 | | - // Map 'active' field to the 'typing'/'isTyping' fields chat_screen expects |
|---|
| 576 | | - final active = json['active'] as bool? ?? true; |
|---|
| 577 | | - json['typing'] = active; |
|---|
| 578 | | - onMessage?.call(json); |
|---|
| 579 | | - return; |
|---|
| 580 | | - } |
|---|
| 581 | | - |
|---|
| 582 | | - // pailot/<sessionId>/screenshot |
|---|
| 583 | | - if (parts.length == 3 && parts[2] == 'screenshot') { |
|---|
| 584 | | - final sessionId = parts[1]; |
|---|
| 585 | | - json['type'] = 'screenshot'; |
|---|
| 586 | | - json['sessionId'] ??= sessionId; |
|---|
| 587 | | - // Map imageBase64 to 'data' for compatibility with chat_screen handler |
|---|
| 588 | | - json['data'] ??= json['imageBase64']; |
|---|
| 585 | + // pailot/out — ALL content messages (text, voice, image, typing, screenshot, transcript) |
|---|
| 586 | + // Each message carries its type and sessionId in the payload. |
|---|
| 587 | + if (topic == 'pailot/out') { |
|---|
| 588 | + final type = json['type'] as String?; |
|---|
| 589 | + // Normalize typing fields for chat_screen |
|---|
| 590 | + if (type == 'typing') { |
|---|
| 591 | + final active = json['active'] as bool? ?? true; |
|---|
| 592 | + json['typing'] = active; |
|---|
| 593 | + } |
|---|
| 594 | + // Normalize screenshot fields |
|---|
| 595 | + if (type == 'screenshot') { |
|---|
| 596 | + json['data'] ??= json['imageBase64']; |
|---|
| 597 | + } |
|---|
| 589 | 598 | onMessage?.call(json); |
|---|
| 590 | 599 | return; |
|---|
| 591 | 600 | } |
|---|
| .. | .. |
|---|
| 603 | 612 | |
|---|
| 604 | 613 | /// Current timestamp in milliseconds. |
|---|
| 605 | 614 | int _now() => DateTime.now().millisecondsSinceEpoch; |
|---|
| 615 | + |
|---|
| 616 | + /// Publish raw bytes to a topic. Used by TraceService for log streaming. |
|---|
| 617 | + void publishRaw(String topic, Uint8Buffer payload, MqttQos qos) { |
|---|
| 618 | + final client = _client; |
|---|
| 619 | + if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) return; |
|---|
| 620 | + try { |
|---|
| 621 | + client.publishMessage(topic, qos, payload); |
|---|
| 622 | + } catch (_) {} |
|---|
| 623 | + } |
|---|
| 606 | 624 | |
|---|
| 607 | 625 | /// Publish a JSON payload to an MQTT topic. |
|---|
| 608 | 626 | void _publish(String topic, Map<String, dynamic> payload, MqttQos qos) { |
|---|
| .. | .. |
|---|
| 770 | 788 | |
|---|
| 771 | 789 | /// Dispose all resources. |
|---|
| 772 | 790 | void dispose() { |
|---|
| 791 | + WidgetsBinding.instance.removeObserver(this); |
|---|
| 773 | 792 | disconnect(); |
|---|
| 774 | 793 | } |
|---|
| 775 | 794 | |
|---|
| .. | .. |
|---|
| 779 | 798 | switch (state) { |
|---|
| 780 | 799 | case AppLifecycleState.resumed: |
|---|
| 781 | 800 | if (_intentionalClose) break; |
|---|
| 782 | | - _mqttLog('MQTT: app resumed, status=$_status client=${_client != null} mqttState=${_client?.connectionStatus?.state}'); |
|---|
| 783 | | - final client = _client; |
|---|
| 784 | | - if (client == null || client.connectionStatus?.state != MqttConnectionState.connected) { |
|---|
| 785 | | - // Clearly disconnected — just reconnect |
|---|
| 786 | | - _mqttLog('MQTT: not connected on resume, reconnecting...'); |
|---|
| 787 | | - _client = null; |
|---|
| 788 | | - _setStatus(ConnectionStatus.reconnecting); |
|---|
| 789 | | - connect(); |
|---|
| 790 | | - } else { |
|---|
| 791 | | - // Appears connected — notify listener to fetch missed messages |
|---|
| 792 | | - // via catch_up. Don't call onOpen (it resets sessionReady and causes flicker). |
|---|
| 793 | | - _mqttLog('MQTT: appears connected on resume, triggering catch_up'); |
|---|
| 794 | | - onResume?.call(); |
|---|
| 795 | | - } |
|---|
| 801 | + _mqttLog('MQTT: app resumed'); |
|---|
| 802 | + // Let autoReconnect handle dead connections (keepalive timeout). |
|---|
| 803 | + // Just trigger catch_up to fetch missed messages and rebuild UI. |
|---|
| 804 | + onResume?.call(); |
|---|
| 796 | 805 | case AppLifecycleState.paused: |
|---|
| 797 | 806 | break; |
|---|
| 798 | 807 | default: |
|---|
| .. | .. |
|---|
| 1 | +import 'dart:convert'; |
|---|
| 2 | + |
|---|
| 1 | 3 | import 'package:flutter/foundation.dart'; |
|---|
| 4 | +import 'package:mqtt_client/mqtt_client.dart'; |
|---|
| 5 | +import 'package:mqtt_client/mqtt_server_client.dart'; |
|---|
| 6 | + |
|---|
| 7 | +import 'mqtt_service.dart'; |
|---|
| 2 | 8 | |
|---|
| 3 | 9 | /// A single trace entry capturing a message-handling event. |
|---|
| 4 | 10 | class TraceEntry { |
|---|
| .. | .. |
|---|
| 22 | 28 | /// Captures message-handling events from MQTT, chat screen, and other |
|---|
| 23 | 29 | /// components. The buffer is capped at [maxEntries] (default 200). |
|---|
| 24 | 30 | /// Works in both debug and release builds. |
|---|
| 31 | +/// |
|---|
| 32 | +/// When an MqttService is attached via [attachMqtt], trace entries are |
|---|
| 33 | +/// automatically published to the server on `pailot/control/in` so they |
|---|
| 34 | +/// can be read from the daemon log. |
|---|
| 25 | 35 | class TraceService { |
|---|
| 26 | 36 | TraceService._(); |
|---|
| 27 | 37 | static final TraceService instance = TraceService._(); |
|---|
| 28 | 38 | |
|---|
| 29 | 39 | static const int maxEntries = 200; |
|---|
| 30 | 40 | final List<TraceEntry> _entries = []; |
|---|
| 41 | + MqttService? _mqtt; |
|---|
| 42 | + |
|---|
| 43 | + /// Attach an MQTT service for auto-publishing traces to the server. |
|---|
| 44 | + void attachMqtt(MqttService mqtt) { |
|---|
| 45 | + _mqtt = mqtt; |
|---|
| 46 | + } |
|---|
| 31 | 47 | |
|---|
| 32 | 48 | /// All entries, oldest first. |
|---|
| 33 | 49 | List<TraceEntry> get entries => List.unmodifiable(_entries); |
|---|
| 34 | 50 | |
|---|
| 35 | 51 | /// Add a trace entry. Oldest entry is evicted once the buffer is full. |
|---|
| 52 | + /// If MQTT is attached and connected, the entry is also published to the server. |
|---|
| 36 | 53 | void addTrace(String event, String details) { |
|---|
| 37 | 54 | _entries.add(TraceEntry( |
|---|
| 38 | 55 | timestamp: DateTime.now(), |
|---|
| .. | .. |
|---|
| 43 | 60 | _entries.removeAt(0); |
|---|
| 44 | 61 | } |
|---|
| 45 | 62 | debugPrint('[TRACE] $event — $details'); |
|---|
| 63 | + |
|---|
| 64 | + // Auto-publish to server if MQTT is connected |
|---|
| 65 | + _publishTrace(event, details); |
|---|
| 66 | + } |
|---|
| 67 | + |
|---|
| 68 | + void _publishTrace(String event, String details) { |
|---|
| 69 | + final mqtt = _mqtt; |
|---|
| 70 | + if (mqtt == null || !mqtt.isConnected) return; |
|---|
| 71 | + try { |
|---|
| 72 | + final payload = jsonEncode({ |
|---|
| 73 | + 'type': 'command', |
|---|
| 74 | + 'command': 'app_trace', |
|---|
| 75 | + 'event': event, |
|---|
| 76 | + 'details': details, |
|---|
| 77 | + 'ts': DateTime.now().millisecondsSinceEpoch, |
|---|
| 78 | + }); |
|---|
| 79 | + final builder = MqttClientPayloadBuilder(); |
|---|
| 80 | + builder.addString(payload); |
|---|
| 81 | + mqtt.publishRaw('pailot/control/in', builder.payload!, MqttQos.atMostOnce); |
|---|
| 82 | + } catch (_) { |
|---|
| 83 | + // Non-fatal — don't let trace logging break the app |
|---|
| 84 | + } |
|---|
| 46 | 85 | } |
|---|
| 47 | 86 | |
|---|
| 48 | 87 | /// Clear all entries. |
|---|
| .. | .. |
|---|
| 5 | 5 | import 'package:flutter/material.dart'; |
|---|
| 6 | 6 | import 'package:flutter/services.dart'; |
|---|
| 7 | 7 | import 'package:flutter_markdown/flutter_markdown.dart'; |
|---|
| 8 | +import 'package:url_launcher/url_launcher.dart'; |
|---|
| 8 | 9 | import 'package:intl/intl.dart'; |
|---|
| 9 | 10 | |
|---|
| 10 | 11 | import '../models/message.dart'; |
|---|
| .. | .. |
|---|
| 165 | 166 | ), |
|---|
| 166 | 167 | onTapLink: (text, href, title) { |
|---|
| 167 | 168 | if (href != null) { |
|---|
| 168 | | - Clipboard.setData(ClipboardData(text: href)); |
|---|
| 169 | | - ScaffoldMessenger.of(context).showSnackBar( |
|---|
| 170 | | - SnackBar( |
|---|
| 171 | | - content: Text('Link copied: $href'), |
|---|
| 172 | | - duration: const Duration(seconds: 2), |
|---|
| 173 | | - ), |
|---|
| 174 | | - ); |
|---|
| 169 | + final uri = Uri.tryParse(href); |
|---|
| 170 | + if (uri != null) { |
|---|
| 171 | + launchUrl(uri, mode: LaunchMode.externalApplication); |
|---|
| 172 | + } |
|---|
| 175 | 173 | } |
|---|
| 176 | 174 | }, |
|---|
| 177 | 175 | ); |
|---|
| .. | .. |
|---|
| 18 | 18 | import record_macos |
|---|
| 19 | 19 | import share_plus |
|---|
| 20 | 20 | import shared_preferences_foundation |
|---|
| 21 | +import url_launcher_macos |
|---|
| 21 | 22 | |
|---|
| 22 | 23 | func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { |
|---|
| 23 | 24 | AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin")) |
|---|
| .. | .. |
|---|
| 33 | 34 | RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin")) |
|---|
| 34 | 35 | SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin")) |
|---|
| 35 | 36 | SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) |
|---|
| 37 | + UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) |
|---|
| 36 | 38 | } |
|---|
| .. | .. |
|---|
| 1061 | 1061 | url: "https://pub.dev" |
|---|
| 1062 | 1062 | source: hosted |
|---|
| 1063 | 1063 | version: "5.0.3" |
|---|
| 1064 | + url_launcher: |
|---|
| 1065 | + dependency: "direct main" |
|---|
| 1066 | + description: |
|---|
| 1067 | + name: url_launcher |
|---|
| 1068 | + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 |
|---|
| 1069 | + url: "https://pub.dev" |
|---|
| 1070 | + source: hosted |
|---|
| 1071 | + version: "6.3.2" |
|---|
| 1072 | + url_launcher_android: |
|---|
| 1073 | + dependency: transitive |
|---|
| 1074 | + description: |
|---|
| 1075 | + name: url_launcher_android |
|---|
| 1076 | + sha256: "3bb000251e55d4a209aa0e2e563309dc9bb2befea2295fd0cec1f51760aac572" |
|---|
| 1077 | + url: "https://pub.dev" |
|---|
| 1078 | + source: hosted |
|---|
| 1079 | + version: "6.3.29" |
|---|
| 1080 | + url_launcher_ios: |
|---|
| 1081 | + dependency: transitive |
|---|
| 1082 | + description: |
|---|
| 1083 | + name: url_launcher_ios |
|---|
| 1084 | + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" |
|---|
| 1085 | + url: "https://pub.dev" |
|---|
| 1086 | + source: hosted |
|---|
| 1087 | + version: "6.4.1" |
|---|
| 1064 | 1088 | url_launcher_linux: |
|---|
| 1065 | 1089 | dependency: transitive |
|---|
| 1066 | 1090 | description: |
|---|
| .. | .. |
|---|
| 1069 | 1093 | url: "https://pub.dev" |
|---|
| 1070 | 1094 | source: hosted |
|---|
| 1071 | 1095 | version: "3.2.2" |
|---|
| 1096 | + url_launcher_macos: |
|---|
| 1097 | + dependency: transitive |
|---|
| 1098 | + description: |
|---|
| 1099 | + name: url_launcher_macos |
|---|
| 1100 | + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" |
|---|
| 1101 | + url: "https://pub.dev" |
|---|
| 1102 | + source: hosted |
|---|
| 1103 | + version: "3.2.5" |
|---|
| 1072 | 1104 | url_launcher_platform_interface: |
|---|
| 1073 | 1105 | dependency: transitive |
|---|
| 1074 | 1106 | description: |
|---|
| .. | .. |
|---|
| 35 | 35 | flutter_app_badger: ^1.5.0 |
|---|
| 36 | 36 | connectivity_plus: ^7.1.0 |
|---|
| 37 | 37 | in_app_purchase: ^3.2.3 |
|---|
| 38 | + url_launcher: ^6.3.2 |
|---|
| 38 | 39 | |
|---|
| 39 | 40 | dev_dependencies: |
|---|
| 40 | 41 | flutter_test: |
|---|