2 files added
5 files modified
changed files
ios/Runner/AppDelegate.swift patch | view | blame | history
ios/Runner/Base.lproj/LaunchScreen.storyboard patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/screens/trace_screen.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/services/trace_service.dart patch | view | blame | history
ios/Runner/AppDelegate.swift
....@@ -43,13 +43,15 @@
4343 super.application(application, didFailToRegisterForRemoteNotificationsWithError: error)
4444 }
4545
46
- // Forward notification presentation (foreground)
46
+ // Suppress push notification display when app is in foreground —
47
+ // the MQTT message handler shows it in-app instead
4748 override func userNotificationCenter(
4849 _ center: UNUserNotificationCenter,
4950 willPresent notification: UNNotification,
5051 withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void
5152 ) {
52
- super.userNotificationCenter(center, willPresent: notification, withCompletionHandler: completionHandler)
53
+ // Don't show banner/sound/badge when app is active — MQTT delivers the message directly
54
+ completionHandler([])
5355 }
5456
5557 // Forward notification tap
ios/Runner/Base.lproj/LaunchScreen.storyboard
....@@ -19,7 +19,7 @@
1919 <imageView opaque="NO" clipsSubviews="YES" multipleTouchEnabled="YES" contentMode="center" image="LaunchImage" translatesAutoresizingMaskIntoConstraints="NO" id="YRO-k0-Ey4">
2020 </imageView>
2121 </subviews>
22
- <color key="backgroundColor" red="1" green="1" blue="1" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
22
+ <color key="backgroundColor" red="0.067" green="0.071" blue="0.090" alpha="1" colorSpace="custom" customColorSpace="sRGB"/>
2323 <constraints>
2424 <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerX" secondItem="Ze5-6b-2t3" secondAttribute="centerX" id="1a2-6s-vTC"/>
2525 <constraint firstItem="YRO-k0-Ey4" firstAttribute="centerY" secondItem="Ze5-6b-2t3" secondAttribute="centerY" id="4X2-HB-R7a"/>
lib/screens/chat_screen.dart
....@@ -20,6 +20,7 @@
2020 import '../services/audio_service.dart';
2121 import '../services/message_store.dart';
2222 import '../services/mqtt_service.dart';
23
+import '../services/trace_service.dart';
2324 import '../services/navigate_notifier.dart';
2425 import '../services/push_service.dart';
2526 import '../theme/app_theme.dart';
....@@ -43,6 +44,7 @@
4344
4445 Future<void> _chatLog(String msg) async {
4546 debugPrint('[Chat] $msg');
47
+ TraceService.instance.addTrace('Chat', msg);
4648 if (!kDebugMode) return;
4749 try {
4850 final dir = await getApplicationDocumentsDirectory();
....@@ -256,12 +258,25 @@
256258
257259 void _handleMessage(Map<String, dynamic> msg) {
258260 final type = msg['type'] as String?;
261
+ final msgSessionId = msg['sessionId'] as String?;
262
+ final msgSeq = msg['seq'];
263
+
264
+ TraceService.instance.addTrace(
265
+ 'handleMessage processing',
266
+ 'type=$type sessionId=${msgSessionId?.substring(0, msgSessionId.length.clamp(0, 8))} seq=$msgSeq',
267
+ );
259268
260269 // Track sequence numbers for catch_up protocol
261270 final seq = msg['seq'] as int?;
262271 if (seq != null) {
263272 // Dedup: skip messages we've already processed
264
- if (_seenSeqs.contains(seq)) return;
273
+ if (_seenSeqs.contains(seq)) {
274
+ TraceService.instance.addTrace(
275
+ 'handleMessage seq deduped',
276
+ 'seq=$seq type=$type — already seen, dropping',
277
+ );
278
+ return;
279
+ }
265280 _seenSeqs.add(seq);
266281 _seenSeqsList.add(seq);
267282 // Keep bounded at 500 with O(1) FIFO eviction (drop oldest first)
....@@ -491,6 +506,11 @@
491506 msg['text'] as String? ??
492507 '';
493508
509
+ TraceService.instance.addTrace(
510
+ 'handleMessage processing type=text',
511
+ 'sessionId=${sessionId?.substring(0, sessionId.length.clamp(0, 8))}',
512
+ );
513
+
494514 final message = Message.text(
495515 role: MessageRole.assistant,
496516 content: content,
....@@ -500,6 +520,10 @@
500520 final activeId = ref.read(activeSessionIdProvider);
501521 if (sessionId != null && sessionId != activeId) {
502522 // Store message for the other session so it's there when user switches
523
+ TraceService.instance.addTrace(
524
+ 'message stored for session',
525
+ 'sessionId=${sessionId.substring(0, sessionId.length.clamp(0, 8))}, toast shown',
526
+ );
503527 await _storeForSession(sessionId, message);
504528 _incrementUnread(sessionId);
505529 final sessions = ref.read(sessionsProvider);
....@@ -516,6 +540,10 @@
516540 );
517541 }
518542 } else {
543
+ TraceService.instance.addTrace(
544
+ 'message displayed in chat',
545
+ 'sessionId=${sessionId?.substring(0, sessionId.length.clamp(0, 8)) ?? "global"} len=${content.length}',
546
+ );
519547 ref.read(messagesProvider.notifier).addMessage(message);
520548 ref.read(isTypingProvider.notifier).state = false;
521549 _scrollToBottom();
lib/screens/settings_screen.dart
....@@ -9,6 +9,7 @@
99 import '../services/wol_service.dart';
1010 import '../theme/app_theme.dart';
1111 import '../widgets/status_dot.dart';
12
+import 'trace_screen.dart';
1213
1314 class SettingsScreen extends ConsumerStatefulWidget {
1415 const SettingsScreen({super.key});
....@@ -371,6 +372,20 @@
371372 icon: const Icon(Icons.shield_outlined),
372373 label: const Text('Reset Server Trust'),
373374 ),
375
+ const SizedBox(height: 12),
376
+
377
+ // Message Trace Log — for diagnosing message delivery problems
378
+ OutlinedButton.icon(
379
+ onPressed: () {
380
+ Navigator.of(context).push(
381
+ MaterialPageRoute<void>(
382
+ builder: (_) => const TraceScreen(),
383
+ ),
384
+ );
385
+ },
386
+ icon: const Icon(Icons.receipt_long_outlined),
387
+ label: const Text('Message Trace Log'),
388
+ ),
374389 const SizedBox(height: 24),
375390
376391 // --- PAILot Pro ---
lib/screens/trace_screen.dart
....@@ -0,0 +1,254 @@
1
+import 'dart:convert';
2
+
3
+import 'package:flutter/material.dart';
4
+import 'package:mqtt_client/mqtt_client.dart';
5
+
6
+import '../services/mqtt_service.dart';
7
+import '../services/trace_service.dart';
8
+
9
+/// Displays the in-memory trace log for diagnosing message delivery problems.
10
+///
11
+/// Shows entries in reverse chronological order (newest first).
12
+/// Accessible from the Settings screen via "Message Trace Log".
13
+/// Works in both debug and release builds.
14
+class TraceScreen extends StatefulWidget {
15
+ /// Optional MQTT service reference so entries can be published to the server.
16
+ final MqttService? mqttService;
17
+
18
+ const TraceScreen({super.key, this.mqttService});
19
+
20
+ @override
21
+ State<TraceScreen> createState() => _TraceScreenState();
22
+}
23
+
24
+class _TraceScreenState extends State<TraceScreen> {
25
+ bool _sending = false;
26
+
27
+ List<TraceEntry> get _entries =>
28
+ TraceService.instance.entries.reversed.toList();
29
+
30
+ Future<void> _sendToServer() async {
31
+ final service = widget.mqttService;
32
+ if (service == null || !service.isConnected) {
33
+ if (mounted) {
34
+ ScaffoldMessenger.of(context).showSnackBar(
35
+ const SnackBar(content: Text('Not connected to server')),
36
+ );
37
+ }
38
+ return;
39
+ }
40
+
41
+ setState(() => _sending = true);
42
+
43
+ try {
44
+ final entries = TraceService.instance.entries;
45
+ final payload = jsonEncode({
46
+ 'type': 'trace_log',
47
+ 'source': 'pailot',
48
+ 'ts': DateTime.now().millisecondsSinceEpoch,
49
+ 'count': entries.length,
50
+ 'entries': entries
51
+ .map((e) => {
52
+ 'timestamp': e.timestamp.toIso8601String(),
53
+ 'event': e.event,
54
+ 'details': e.details,
55
+ })
56
+ .toList(),
57
+ });
58
+
59
+ // Build the MQTT payload
60
+ final builder = MqttClientPayloadBuilder();
61
+ builder.addString(payload);
62
+
63
+ // Publish on pailot/trace — daemon can subscribe/log this
64
+ service.send({
65
+ 'type': 'command',
66
+ 'command': 'trace_upload',
67
+ 'args': {
68
+ 'count': entries.length,
69
+ 'payload': payload,
70
+ },
71
+ });
72
+
73
+ if (mounted) {
74
+ ScaffoldMessenger.of(context).showSnackBar(
75
+ SnackBar(
76
+ content: Text('Sent ${entries.length} trace entries to server'),
77
+ ),
78
+ );
79
+ }
80
+ } catch (e) {
81
+ if (mounted) {
82
+ ScaffoldMessenger.of(context).showSnackBar(
83
+ SnackBar(content: Text('Send failed: $e')),
84
+ );
85
+ }
86
+ } finally {
87
+ if (mounted) setState(() => _sending = false);
88
+ }
89
+ }
90
+
91
+ void _clearLog() {
92
+ TraceService.instance.clear();
93
+ setState(() {});
94
+ }
95
+
96
+ Color _colorForEvent(String event) {
97
+ if (event.contains('error') || event.contains('fail') || event.contains('drop')) {
98
+ return Colors.red.shade300;
99
+ }
100
+ if (event.contains('dedup')) return Colors.orange.shade300;
101
+ if (event.contains('displayed') || event.contains('published')) {
102
+ return Colors.green.shade300;
103
+ }
104
+ if (event.contains('MQTT')) return Colors.blue.shade300;
105
+ if (event.contains('voice')) return Colors.purple.shade300;
106
+ return Colors.grey.shade400;
107
+ }
108
+
109
+ @override
110
+ Widget build(BuildContext context) {
111
+ final entries = _entries;
112
+
113
+ return Scaffold(
114
+ appBar: AppBar(
115
+ title: const Text('Message Trace Log'),
116
+ actions: [
117
+ IconButton(
118
+ icon: const Icon(Icons.delete_outline),
119
+ tooltip: 'Clear log',
120
+ onPressed: _clearLog,
121
+ ),
122
+ IconButton(
123
+ icon: _sending
124
+ ? const SizedBox(
125
+ width: 20,
126
+ height: 20,
127
+ child: CircularProgressIndicator(strokeWidth: 2),
128
+ )
129
+ : const Icon(Icons.upload_outlined),
130
+ tooltip: 'Send to server',
131
+ onPressed: _sending ? null : _sendToServer,
132
+ ),
133
+ ],
134
+ ),
135
+ body: entries.isEmpty
136
+ ? const Center(
137
+ child: Text(
138
+ 'No trace entries yet.\nSend a message to generate traces.',
139
+ textAlign: TextAlign.center,
140
+ style: TextStyle(color: Colors.grey),
141
+ ),
142
+ )
143
+ : Column(
144
+ children: [
145
+ Container(
146
+ color: Colors.black87,
147
+ padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
148
+ child: Row(
149
+ children: [
150
+ Text(
151
+ '${entries.length} entries (newest first)',
152
+ style: const TextStyle(
153
+ fontSize: 11,
154
+ color: Colors.grey,
155
+ fontFamily: 'monospace',
156
+ ),
157
+ ),
158
+ const Spacer(),
159
+ const Text(
160
+ 'Tap to copy',
161
+ style: TextStyle(fontSize: 11, color: Colors.grey),
162
+ ),
163
+ ],
164
+ ),
165
+ ),
166
+ Expanded(
167
+ child: ListView.builder(
168
+ itemCount: entries.length,
169
+ itemBuilder: (context, index) {
170
+ final entry = entries[index];
171
+ final ts = entry.timestamp
172
+ .toIso8601String()
173
+ .substring(11, 23); // HH:mm:ss.mmm
174
+
175
+ return InkWell(
176
+ onTap: () {
177
+ // Copy full entry to clipboard on tap
178
+ final text = '$ts | ${entry.event} | ${entry.details}';
179
+ ScaffoldMessenger.of(context).showSnackBar(
180
+ SnackBar(
181
+ content: Text(
182
+ text,
183
+ maxLines: 2,
184
+ overflow: TextOverflow.ellipsis,
185
+ style: const TextStyle(fontFamily: 'monospace', fontSize: 11),
186
+ ),
187
+ duration: const Duration(seconds: 2),
188
+ ),
189
+ );
190
+ },
191
+ child: Container(
192
+ padding: const EdgeInsets.symmetric(
193
+ horizontal: 12,
194
+ vertical: 6,
195
+ ),
196
+ decoration: BoxDecoration(
197
+ border: Border(
198
+ bottom: BorderSide(
199
+ color: Colors.grey.shade800,
200
+ width: 0.5,
201
+ ),
202
+ ),
203
+ ),
204
+ child: Row(
205
+ crossAxisAlignment: CrossAxisAlignment.start,
206
+ children: [
207
+ Text(
208
+ ts,
209
+ style: const TextStyle(
210
+ fontFamily: 'monospace',
211
+ fontSize: 10,
212
+ color: Colors.grey,
213
+ ),
214
+ ),
215
+ const SizedBox(width: 8),
216
+ Expanded(
217
+ child: Column(
218
+ crossAxisAlignment: CrossAxisAlignment.start,
219
+ children: [
220
+ Text(
221
+ entry.event,
222
+ style: TextStyle(
223
+ fontFamily: 'monospace',
224
+ fontSize: 11,
225
+ fontWeight: FontWeight.bold,
226
+ color: _colorForEvent(entry.event),
227
+ ),
228
+ ),
229
+ if (entry.details.isNotEmpty)
230
+ Text(
231
+ entry.details,
232
+ style: const TextStyle(
233
+ fontFamily: 'monospace',
234
+ fontSize: 10,
235
+ color: Colors.grey,
236
+ ),
237
+ maxLines: 2,
238
+ overflow: TextOverflow.ellipsis,
239
+ ),
240
+ ],
241
+ ),
242
+ ),
243
+ ],
244
+ ),
245
+ ),
246
+ );
247
+ },
248
+ ),
249
+ ),
250
+ ],
251
+ ),
252
+ );
253
+ }
254
+}
lib/services/mqtt_service.dart
....@@ -15,6 +15,7 @@
1515 import 'package:uuid/uuid.dart';
1616
1717 import '../models/server_config.dart';
18
+import 'trace_service.dart';
1819 import 'wol_service.dart';
1920
2021 /// Connection status for the MQTT client.
....@@ -25,9 +26,11 @@
2526 reconnecting,
2627 }
2728
28
-// Debug log — writes to file only in debug builds, always prints via debugPrint
29
+// Debug log — writes to file only in debug builds, always prints via debugPrint.
30
+// Also adds entries to TraceService so they appear in the trace log viewer.
2931 Future<void> _mqttLog(String msg) async {
3032 debugPrint('[MQTT] $msg');
33
+ TraceService.instance.addTrace('MQTT', msg);
3134 if (!kDebugMode) return;
3235 try {
3336 final dir = await pp.getApplicationDocumentsDirectory();
....@@ -107,35 +110,8 @@
107110 _intentionalClose = false;
108111 _setStatus(ConnectionStatus.connecting);
109112
110
- // Start listening for network changes (WiFi↔cellular, VPN connect/disconnect)
111
- _connectivitySub ??= Connectivity().onConnectivityChanged.listen((results) {
112
- if (_lastConnectivity != null && !_intentionalClose) {
113
- final changed = results.length != _lastConnectivity!.length ||
114
- !results.every((r) => _lastConnectivity!.contains(r));
115
- if (changed) {
116
- _mqttLog('MQTT: network changed: ${results.map((r) => r.name).join(",")} — forcing reconnect');
117
- // Force disconnect and reconnect on new network
118
- final client = _client;
119
- if (client != null) {
120
- _intentionalClose = true;
121
- client.autoReconnect = false;
122
- try { client.disconnect(); } catch (_) {}
123
- _client = null;
124
- _updatesSub?.cancel();
125
- _updatesSub = null;
126
- _intentionalClose = false;
127
- }
128
- _lastDiscoveredHost = null; // Clear cached discovery — subnet may have changed
129
- connectedHost = null;
130
- connectedVia = null;
131
- _setStatus(ConnectionStatus.reconnecting);
132
- Future.delayed(const Duration(milliseconds: 500), () {
133
- if (!_intentionalClose) connect();
134
- });
135
- }
136
- }
137
- _lastConnectivity = results;
138
- });
113
+ // Network change detection disabled — was causing spurious reconnects.
114
+ // MQTT keepalive + auto-reconnect handles dead connections reliably.
139115
140116 // Load trusted cert fingerprint for TOFU verification
141117 if (_trustedFingerprint == null) await _loadTrustedFingerprint();
....@@ -517,12 +493,27 @@
517493 // Dedup by msgId
518494 final msgId = json['msgId'] as String?;
519495 if (msgId != null) {
520
- if (_seenMsgIds.contains(msgId)) continue;
496
+ if (_seenMsgIds.contains(msgId)) {
497
+ final seq = json['seq'];
498
+ final type = json['type'] as String? ?? '?';
499
+ TraceService.instance.addTrace(
500
+ 'MQTT deduped',
501
+ 'msgId=${msgId.substring(0, 8)} type=$type seq=$seq topic=${msg.topic}',
502
+ );
503
+ continue;
504
+ }
521505 _seenMsgIds.add(msgId);
522506 _seenMsgIdOrder.add(msgId);
523507 _evictOldIds();
524508 }
525509
510
+ final seq = json['seq'];
511
+ final type = json['type'] as String? ?? '?';
512
+ TraceService.instance.addTrace(
513
+ 'MQTT received',
514
+ 'seq=$seq type=$type on ${msg.topic}',
515
+ );
516
+
526517 // Dispatch: parse topic to enrich the message with routing info
527518 _dispatchMessage(msg.topic, json);
528519 }
lib/services/trace_service.dart
....@@ -0,0 +1,50 @@
1
+import 'package:flutter/foundation.dart';
2
+
3
+/// A single trace entry capturing a message-handling event.
4
+class TraceEntry {
5
+ final DateTime timestamp;
6
+ final String event;
7
+ final String details;
8
+
9
+ const TraceEntry({
10
+ required this.timestamp,
11
+ required this.event,
12
+ required this.details,
13
+ });
14
+
15
+ @override
16
+ String toString() =>
17
+ '[${timestamp.toIso8601String().substring(11, 23)}] $event — $details';
18
+}
19
+
20
+/// Singleton ring-buffer trace service.
21
+///
22
+/// Captures message-handling events from MQTT, chat screen, and other
23
+/// components. The buffer is capped at [maxEntries] (default 200).
24
+/// Works in both debug and release builds.
25
+class TraceService {
26
+ TraceService._();
27
+ static final TraceService instance = TraceService._();
28
+
29
+ static const int maxEntries = 200;
30
+ final List<TraceEntry> _entries = [];
31
+
32
+ /// All entries, oldest first.
33
+ List<TraceEntry> get entries => List.unmodifiable(_entries);
34
+
35
+ /// Add a trace entry. Oldest entry is evicted once the buffer is full.
36
+ void addTrace(String event, String details) {
37
+ _entries.add(TraceEntry(
38
+ timestamp: DateTime.now(),
39
+ event: event,
40
+ details: details,
41
+ ));
42
+ if (_entries.length > maxEntries) {
43
+ _entries.removeAt(0);
44
+ }
45
+ debugPrint('[TRACE] $event — $details');
46
+ }
47
+
48
+ /// Clear all entries.
49
+ void clear() => _entries.clear();
50
+}