Matthias Nott
2026-03-25 d6cf9469aa0462d1b8313cc85907176eee1214a2
fix: C3 debug logs, H1-H5 image cache, temp files, controller leak, validation, lifecycle
7 files modified
changed files
TODO-appstore.md patch | view | blame | history
lib/screens/chat_screen.dart patch | view | blame | history
lib/screens/settings_screen.dart patch | view | blame | history
lib/services/audio_service.dart patch | view | blame | history
lib/services/mqtt_service.dart patch | view | blame | history
lib/widgets/message_bubble.dart patch | view | blame | history
lib/widgets/session_drawer.dart patch | view | blame | history
TODO-appstore.md
....@@ -6,16 +6,16 @@
66 ## CRITICAL (Must fix before submission)
77
88 - [x] **C1: Remove NSAllowsArbitraryLoads** — ATS bypass, Apple will reject. Use NSAllowsLocalNetworking only *(fixed 2026-03-25)*
9
-- [ ] **C2: Add TLS to MQTT** — All conversations and auth token travel in plaintext. Set `client.secure = true`, configure TLS on AIBroker broker
10
-- [ ] **C3: Remove debug log files in production** — `mqtt_debug.log` and `_chatLog` write truncated message content to Documents. Wrap in `kDebugMode` or remove entirely
9
+- [x] **C2: Add TLS to MQTT** — All conversations and auth token travel in plaintext. Set `client.secure = true`, configure TLS on AIBroker broker *(fixed 2026-03-25 — self-signed cert auto-generated at ~/.aibroker/tls/, onBadCertificate accepts it; TODO: pin cert fingerprint)*
10
+- [x] **C3: Remove debug log files in production** — `mqtt_debug.log` and `_chatLog` write truncated message content to Documents. Wrap in `kDebugMode` or remove entirely *(fixed 2026-03-25)*
1111
1212 ## HIGH (Should fix before submission)
1313
14
-- [ ] **H1: Unbounded image cache** — `_imageCache` in message_bubble.dart grows without limit. Add LRU eviction (cap at 50)
15
-- [ ] **H2: Audio temp files never cleaned** — `_base64ToFile` creates .m4a files never deleted. Clean up after playback completes
16
-- [ ] **H3: TextEditingController leak** — Rename dialog in session_drawer.dart creates controller but never disposes it
17
-- [ ] **H4: Input validation on settings** — No validation on host IPs, port range, MAC format. Add regex validators
18
-- [ ] **H5: LifecycleObserver never removed** — AudioService.init() adds observer but dispose() doesn't remove it
14
+- [x] **H1: Unbounded image cache** — `_imageCache` in message_bubble.dart grows without limit. Add LRU eviction (cap at 50) *(fixed 2026-03-25)*
15
+- [x] **H2: Audio temp files never cleaned** — `_base64ToFile` creates .m4a files never deleted. Clean up after playback completes *(fixed 2026-03-25)*
16
+- [x] **H3: TextEditingController leak** — Rename dialog in session_drawer.dart creates controller but never disposes it *(fixed 2026-03-25)*
17
+- [x] **H4: Input validation on settings** — No validation on host IPs, port range, MAC format. Add regex validators *(fixed 2026-03-25)*
18
+- [x] **H5: LifecycleObserver never removed** — AudioService.init() adds observer but dispose() doesn't remove it *(fixed 2026-03-25)*
1919 - [ ] **H6: MQTT token in memory** — Acceptable for personal use, document as known limitation
2020
2121 ## MEDIUM (Improve before submission)
....@@ -51,4 +51,4 @@
5151 | UIBackgroundModes: audio | PASS | - |
5252 | Privacy Policy | FAIL | Fix L2 |
5353 | PrivacyInfo.xcprivacy | FAIL | Fix L1 |
54
-| TLS for network | FAIL | Fix C2 |
54
+| TLS for network | PASS | Fixed C2 - self-signed cert, onBadCertificate=true |
lib/screens/chat_screen.dart
....@@ -1,6 +1,7 @@
11 import 'dart:convert';
22 import 'dart:io';
33
4
+import 'package:flutter/foundation.dart';
45 import 'package:path_provider/path_provider.dart';
56
67 import 'package:flutter/material.dart';
....@@ -36,6 +37,8 @@
3637 }
3738
3839 Future<void> _chatLog(String msg) async {
40
+ debugPrint('[Chat] $msg');
41
+ if (!kDebugMode) return;
3942 try {
4043 final dir = await getApplicationDocumentsDirectory();
4144 final file = File('${dir.path}/mqtt_debug.log');
lib/screens/settings_screen.dart
....@@ -158,6 +158,11 @@
158158 hintText: '192.168.1.100',
159159 ),
160160 keyboardType: TextInputType.url,
161
+ validator: (v) {
162
+ if (v == null || v.trim().isEmpty) return null;
163
+ final ip = RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$');
164
+ return ip.hasMatch(v.trim()) ? null : 'Enter a valid IP address';
165
+ },
161166 ),
162167 const SizedBox(height: 16),
163168
....@@ -171,6 +176,11 @@
171176 hintText: '10.8.0.1 (OpenVPN static IP)',
172177 ),
173178 keyboardType: TextInputType.url,
179
+ validator: (v) {
180
+ if (v == null || v.trim().isEmpty) return null;
181
+ final ip = RegExp(r'^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}$');
182
+ return ip.hasMatch(v.trim()) ? null : 'Enter a valid IP address';
183
+ },
174184 ),
175185 const SizedBox(height: 16),
176186
....@@ -198,6 +208,14 @@
198208 hintText: '8765',
199209 ),
200210 keyboardType: TextInputType.number,
211
+ validator: (v) {
212
+ if (v == null || v.trim().isEmpty) return 'Required';
213
+ final port = int.tryParse(v.trim());
214
+ if (port == null || port < 1 || port > 65535) {
215
+ return 'Port must be 1–65535';
216
+ }
217
+ return null;
218
+ },
201219 ),
202220 const SizedBox(height: 16),
203221
....@@ -210,6 +228,14 @@
210228 decoration: const InputDecoration(
211229 hintText: 'AA:BB:CC:DD:EE:FF',
212230 ),
231
+ validator: (v) {
232
+ if (v == null || v.trim().isEmpty) return null;
233
+ final mac = RegExp(
234
+ r'^[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}$');
235
+ return mac.hasMatch(v.trim())
236
+ ? null
237
+ : 'Enter a valid MAC address (AA:BB:CC:DD:EE:FF)';
238
+ },
213239 ),
214240 const SizedBox(height: 16),
215241
lib/services/audio_service.dart
....@@ -28,9 +28,16 @@
2828 // Autoplay suppression
2929 static bool _isBackgrounded = false;
3030
31
+ // Track last played temp file so it can be cleaned up when the track ends
32
+ static String? _lastPlaybackTempPath;
33
+
34
+ // Lifecycle observer stored so we can remove it in dispose()
35
+ static _LifecycleObserver? _lifecycleObserver;
36
+
3137 /// Initialize the audio service and set up lifecycle observer.
3238 static void init() {
33
- WidgetsBinding.instance.addObserver(_LifecycleObserver());
39
+ _lifecycleObserver = _LifecycleObserver();
40
+ WidgetsBinding.instance.addObserver(_lifecycleObserver!);
3441
3542 // Configure audio session for background playback
3643 _player.setAudioContext(AudioContext(
....@@ -52,6 +59,13 @@
5259 }
5360
5461 static void _onTrackComplete() {
62
+ // Clean up the temp file that just finished playing
63
+ final prev = _lastPlaybackTempPath;
64
+ _lastPlaybackTempPath = null;
65
+ if (prev != null) {
66
+ File(prev).delete().ignore();
67
+ }
68
+
5569 if (_queue.isNotEmpty) {
5670 _playNextInQueue();
5771 } else {
....@@ -68,6 +82,7 @@
6882 }
6983
7084 final path = _queue.removeAt(0);
85
+ _lastPlaybackTempPath = path;
7186 try {
7287 // Brief pause between tracks — iOS audio player needs time to reset
7388 await _player.stop();
....@@ -143,10 +158,12 @@
143158
144159 if (source.startsWith('/')) {
145160 await _player.play(DeviceFileSource(source));
161
+ // File path owned by caller — not tracked for deletion
146162 } else {
147163 // base64 data — write to temp file first
148164 final path = await _base64ToFile(source);
149165 if (path == null) return;
166
+ _lastPlaybackTempPath = path;
150167 await _player.play(DeviceFileSource(path));
151168 }
152169 _isPlaying = true;
....@@ -159,6 +176,7 @@
159176 final path = await _base64ToFile(base64Audio);
160177 if (path == null) return;
161178
179
+ _lastPlaybackTempPath = path;
162180 await _player.play(DeviceFileSource(path));
163181 _isPlaying = true;
164182 onPlaybackStateChanged?.call();
....@@ -177,6 +195,7 @@
177195 debugPrint('AudioService: queued (queue size: ${_queue.length})');
178196 } else {
179197 // Nothing playing — start immediately
198
+ _lastPlaybackTempPath = path;
180199 try {
181200 await _player.play(DeviceFileSource(path));
182201 _isPlaying = true;
....@@ -250,6 +269,10 @@
250269 }
251270
252271 static Future<void> dispose() async {
272
+ if (_lifecycleObserver != null) {
273
+ WidgetsBinding.instance.removeObserver(_lifecycleObserver!);
274
+ _lifecycleObserver = null;
275
+ }
253276 await cancelRecording();
254277 await stopPlayback();
255278 _recorder.dispose();
lib/services/mqtt_service.dart
....@@ -5,6 +5,7 @@
55 import 'package:crypto/crypto.dart';
66
77 import 'package:bonsoir/bonsoir.dart';
8
+import 'package:flutter/foundation.dart';
89 import 'package:flutter/widgets.dart';
910 import 'package:path_provider/path_provider.dart' as pp;
1011 import 'package:mqtt_client/mqtt_client.dart';
....@@ -23,8 +24,10 @@
2324 reconnecting,
2425 }
2526
26
-// Debug log to file (survives release builds)
27
+// Debug log — writes to file only in debug builds, always prints via debugPrint
2728 Future<void> _mqttLog(String msg) async {
29
+ debugPrint('[MQTT] $msg');
30
+ if (!kDebugMode) return;
2831 try {
2932 final dir = await pp.getApplicationDocumentsDirectory();
3033 final file = File('${dir.path}/mqtt_debug.log');
lib/widgets/message_bubble.dart
....@@ -270,13 +270,17 @@
270270 return const Text('Image unavailable');
271271 }
272272
273
- // Cache decoded bytes to prevent flicker on rebuild
274
- final bytes = _imageCache.putIfAbsent(message.id, () {
273
+ // Cache decoded bytes to prevent flicker on rebuild; evict oldest if over 50 entries
274
+ if (!_imageCache.containsKey(message.id)) {
275
+ if (_imageCache.length >= 50) {
276
+ _imageCache.remove(_imageCache.keys.first);
277
+ }
275278 final raw = message.imageBase64!;
276
- return Uint8List.fromList(base64Decode(
279
+ _imageCache[message.id] = Uint8List.fromList(base64Decode(
277280 raw.contains(',') ? raw.split(',').last : raw,
278281 ));
279
- });
282
+ }
283
+ final bytes = _imageCache[message.id]!;
280284
281285 return Column(
282286 crossAxisAlignment: CrossAxisAlignment.start,
lib/widgets/session_drawer.dart
....@@ -233,7 +233,6 @@
233233 ),
234234 ],
235235 ),
236
- );
237
- // Dispose controller when dialog is dismissed
236
+ ).then((_) => controller.dispose());
238237 }
239238 }