fix: C3 debug logs, H1-H5 image cache, temp files, controller leak, validation, lifecycle
| .. | .. |
|---|
| 6 | 6 | ## CRITICAL (Must fix before submission) |
|---|
| 7 | 7 | |
|---|
| 8 | 8 | - [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)* |
|---|
| 11 | 11 | |
|---|
| 12 | 12 | ## HIGH (Should fix before submission) |
|---|
| 13 | 13 | |
|---|
| 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)* |
|---|
| 19 | 19 | - [ ] **H6: MQTT token in memory** — Acceptable for personal use, document as known limitation |
|---|
| 20 | 20 | |
|---|
| 21 | 21 | ## MEDIUM (Improve before submission) |
|---|
| .. | .. |
|---|
| 51 | 51 | | UIBackgroundModes: audio | PASS | - | |
|---|
| 52 | 52 | | Privacy Policy | FAIL | Fix L2 | |
|---|
| 53 | 53 | | PrivacyInfo.xcprivacy | FAIL | Fix L1 | |
|---|
| 54 | | -| TLS for network | FAIL | Fix C2 | |
|---|
| 54 | +| TLS for network | PASS | Fixed C2 - self-signed cert, onBadCertificate=true | |
|---|
| .. | .. |
|---|
| 1 | 1 | import 'dart:convert'; |
|---|
| 2 | 2 | import 'dart:io'; |
|---|
| 3 | 3 | |
|---|
| 4 | +import 'package:flutter/foundation.dart'; |
|---|
| 4 | 5 | import 'package:path_provider/path_provider.dart'; |
|---|
| 5 | 6 | |
|---|
| 6 | 7 | import 'package:flutter/material.dart'; |
|---|
| .. | .. |
|---|
| 36 | 37 | } |
|---|
| 37 | 38 | |
|---|
| 38 | 39 | Future<void> _chatLog(String msg) async { |
|---|
| 40 | + debugPrint('[Chat] $msg'); |
|---|
| 41 | + if (!kDebugMode) return; |
|---|
| 39 | 42 | try { |
|---|
| 40 | 43 | final dir = await getApplicationDocumentsDirectory(); |
|---|
| 41 | 44 | final file = File('${dir.path}/mqtt_debug.log'); |
|---|
| .. | .. |
|---|
| 158 | 158 | hintText: '192.168.1.100', |
|---|
| 159 | 159 | ), |
|---|
| 160 | 160 | 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 | + }, |
|---|
| 161 | 166 | ), |
|---|
| 162 | 167 | const SizedBox(height: 16), |
|---|
| 163 | 168 | |
|---|
| .. | .. |
|---|
| 171 | 176 | hintText: '10.8.0.1 (OpenVPN static IP)', |
|---|
| 172 | 177 | ), |
|---|
| 173 | 178 | 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 | + }, |
|---|
| 174 | 184 | ), |
|---|
| 175 | 185 | const SizedBox(height: 16), |
|---|
| 176 | 186 | |
|---|
| .. | .. |
|---|
| 198 | 208 | hintText: '8765', |
|---|
| 199 | 209 | ), |
|---|
| 200 | 210 | 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 | + }, |
|---|
| 201 | 219 | ), |
|---|
| 202 | 220 | const SizedBox(height: 16), |
|---|
| 203 | 221 | |
|---|
| .. | .. |
|---|
| 210 | 228 | decoration: const InputDecoration( |
|---|
| 211 | 229 | hintText: 'AA:BB:CC:DD:EE:FF', |
|---|
| 212 | 230 | ), |
|---|
| 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 | + }, |
|---|
| 213 | 239 | ), |
|---|
| 214 | 240 | const SizedBox(height: 16), |
|---|
| 215 | 241 | |
|---|
| .. | .. |
|---|
| 28 | 28 | // Autoplay suppression |
|---|
| 29 | 29 | static bool _isBackgrounded = false; |
|---|
| 30 | 30 | |
|---|
| 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 | + |
|---|
| 31 | 37 | /// Initialize the audio service and set up lifecycle observer. |
|---|
| 32 | 38 | static void init() { |
|---|
| 33 | | - WidgetsBinding.instance.addObserver(_LifecycleObserver()); |
|---|
| 39 | + _lifecycleObserver = _LifecycleObserver(); |
|---|
| 40 | + WidgetsBinding.instance.addObserver(_lifecycleObserver!); |
|---|
| 34 | 41 | |
|---|
| 35 | 42 | // Configure audio session for background playback |
|---|
| 36 | 43 | _player.setAudioContext(AudioContext( |
|---|
| .. | .. |
|---|
| 52 | 59 | } |
|---|
| 53 | 60 | |
|---|
| 54 | 61 | 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 | + |
|---|
| 55 | 69 | if (_queue.isNotEmpty) { |
|---|
| 56 | 70 | _playNextInQueue(); |
|---|
| 57 | 71 | } else { |
|---|
| .. | .. |
|---|
| 68 | 82 | } |
|---|
| 69 | 83 | |
|---|
| 70 | 84 | final path = _queue.removeAt(0); |
|---|
| 85 | + _lastPlaybackTempPath = path; |
|---|
| 71 | 86 | try { |
|---|
| 72 | 87 | // Brief pause between tracks — iOS audio player needs time to reset |
|---|
| 73 | 88 | await _player.stop(); |
|---|
| .. | .. |
|---|
| 143 | 158 | |
|---|
| 144 | 159 | if (source.startsWith('/')) { |
|---|
| 145 | 160 | await _player.play(DeviceFileSource(source)); |
|---|
| 161 | + // File path owned by caller — not tracked for deletion |
|---|
| 146 | 162 | } else { |
|---|
| 147 | 163 | // base64 data — write to temp file first |
|---|
| 148 | 164 | final path = await _base64ToFile(source); |
|---|
| 149 | 165 | if (path == null) return; |
|---|
| 166 | + _lastPlaybackTempPath = path; |
|---|
| 150 | 167 | await _player.play(DeviceFileSource(path)); |
|---|
| 151 | 168 | } |
|---|
| 152 | 169 | _isPlaying = true; |
|---|
| .. | .. |
|---|
| 159 | 176 | final path = await _base64ToFile(base64Audio); |
|---|
| 160 | 177 | if (path == null) return; |
|---|
| 161 | 178 | |
|---|
| 179 | + _lastPlaybackTempPath = path; |
|---|
| 162 | 180 | await _player.play(DeviceFileSource(path)); |
|---|
| 163 | 181 | _isPlaying = true; |
|---|
| 164 | 182 | onPlaybackStateChanged?.call(); |
|---|
| .. | .. |
|---|
| 177 | 195 | debugPrint('AudioService: queued (queue size: ${_queue.length})'); |
|---|
| 178 | 196 | } else { |
|---|
| 179 | 197 | // Nothing playing — start immediately |
|---|
| 198 | + _lastPlaybackTempPath = path; |
|---|
| 180 | 199 | try { |
|---|
| 181 | 200 | await _player.play(DeviceFileSource(path)); |
|---|
| 182 | 201 | _isPlaying = true; |
|---|
| .. | .. |
|---|
| 250 | 269 | } |
|---|
| 251 | 270 | |
|---|
| 252 | 271 | static Future<void> dispose() async { |
|---|
| 272 | + if (_lifecycleObserver != null) { |
|---|
| 273 | + WidgetsBinding.instance.removeObserver(_lifecycleObserver!); |
|---|
| 274 | + _lifecycleObserver = null; |
|---|
| 275 | + } |
|---|
| 253 | 276 | await cancelRecording(); |
|---|
| 254 | 277 | await stopPlayback(); |
|---|
| 255 | 278 | _recorder.dispose(); |
|---|
| .. | .. |
|---|
| 5 | 5 | import 'package:crypto/crypto.dart'; |
|---|
| 6 | 6 | |
|---|
| 7 | 7 | import 'package:bonsoir/bonsoir.dart'; |
|---|
| 8 | +import 'package:flutter/foundation.dart'; |
|---|
| 8 | 9 | import 'package:flutter/widgets.dart'; |
|---|
| 9 | 10 | import 'package:path_provider/path_provider.dart' as pp; |
|---|
| 10 | 11 | import 'package:mqtt_client/mqtt_client.dart'; |
|---|
| .. | .. |
|---|
| 23 | 24 | reconnecting, |
|---|
| 24 | 25 | } |
|---|
| 25 | 26 | |
|---|
| 26 | | -// Debug log to file (survives release builds) |
|---|
| 27 | +// Debug log — writes to file only in debug builds, always prints via debugPrint |
|---|
| 27 | 28 | Future<void> _mqttLog(String msg) async { |
|---|
| 29 | + debugPrint('[MQTT] $msg'); |
|---|
| 30 | + if (!kDebugMode) return; |
|---|
| 28 | 31 | try { |
|---|
| 29 | 32 | final dir = await pp.getApplicationDocumentsDirectory(); |
|---|
| 30 | 33 | final file = File('${dir.path}/mqtt_debug.log'); |
|---|
| .. | .. |
|---|
| 270 | 270 | return const Text('Image unavailable'); |
|---|
| 271 | 271 | } |
|---|
| 272 | 272 | |
|---|
| 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 | + } |
|---|
| 275 | 278 | final raw = message.imageBase64!; |
|---|
| 276 | | - return Uint8List.fromList(base64Decode( |
|---|
| 279 | + _imageCache[message.id] = Uint8List.fromList(base64Decode( |
|---|
| 277 | 280 | raw.contains(',') ? raw.split(',').last : raw, |
|---|
| 278 | 281 | )); |
|---|
| 279 | | - }); |
|---|
| 282 | + } |
|---|
| 283 | + final bytes = _imageCache[message.id]!; |
|---|
| 280 | 284 | |
|---|
| 281 | 285 | return Column( |
|---|
| 282 | 286 | crossAxisAlignment: CrossAxisAlignment.start, |
|---|
| .. | .. |
|---|
| 233 | 233 | ), |
|---|
| 234 | 234 | ], |
|---|
| 235 | 235 | ), |
|---|
| 236 | | - ); |
|---|
| 237 | | - // Dispose controller when dialog is dismissed |
|---|
| 236 | + ).then((_) => controller.dispose()); |
|---|
| 238 | 237 | } |
|---|
| 239 | 238 | } |
|---|