1 files added
14 files modified
changed files
TODO-appstore.md patch | view | blame | history
ios/Podfile.lock patch | view | blame | history
ios/Runner/Info.plist patch | view | blame | history
lib/providers/providers.dart 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
macos/Flutter/GeneratedPluginRegistrant.swift patch | view | blame | history
pubspec.lock patch | view | blame | history
pubspec.yaml patch | view | blame | history
windows/flutter/generated_plugin_registrant.cc patch | view | blame | history
windows/flutter/generated_plugins.cmake patch | view | blame | history
TODO-appstore.md
....@@ -0,0 +1,54 @@
1
+# PAILot App Store Readiness Checklist
2
+
3
+Date: 2026-03-25
4
+Source: Security & Code Quality Review
5
+
6
+## CRITICAL (Must fix before submission)
7
+
8
+- [x] **C1: Remove NSAllowsArbitraryLoads** — ATS bypass, Apple will reject. Use NSAllowsLocalNetworking only *(fixed 2026-03-25)*
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
+
12
+## HIGH (Should fix before submission)
13
+
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
+- [ ] **H6: MQTT token in memory** — Acceptable for personal use, document as known limitation
20
+
21
+## MEDIUM (Improve before submission)
22
+
23
+- [x] **M1: Subnet scan hammers 254 hosts** — Batched in groups of 20 with early exit *(fixed 2026-03-25)*
24
+- [x] **M2: No loadMore debounce** — Added isLoadingMore guard *(fixed 2026-03-25)*
25
+- [ ] **M3: NavigateNotifier global singleton** — Mutable static, stale reference risk. Move to Riverpod provider
26
+- [ ] **M4: Unbounded _seenSeqs set** — O(n log n) eviction. Use FIFO deque instead
27
+- [ ] **M5: Screenshots bloat iCloud backup** — Store images as files, not base64 in JSON. Or exclude from backup
28
+- [x] **M6: Unused import** — Already removed *(fixed 2026-03-25)*
29
+- [x] **M7: Silent error swallowing** — Added debugPrint on config load failure *(fixed 2026-03-25)*
30
+
31
+## LOW (Nice-to-haves)
32
+
33
+- [ ] **L1: PrivacyInfo.xcprivacy** — Required since 2024 for UserDefaults and FileTimestamp APIs
34
+- [ ] **L2: Privacy policy URL** — Required for microphone/camera access apps
35
+- [x] **L3: Unused dependencies** — Removed web_socket_channel and wakelock_plus *(fixed 2026-03-25)*
36
+- [x] **L4: Unnecessary _http._tcp** — Removed from NSBonjourServices *(fixed 2026-03-25)*
37
+- [x] **L5: Typing indicator timeout** — Auto-clear after 10s *(fixed 2026-03-25)*
38
+- [ ] **L6: Version number** — Default 1.0.0+1, set correctly before submission
39
+- [ ] **L7: App icon** — Verify meets Apple guidelines (no alpha channel, correct sizes)
40
+
41
+## App Store Requirements
42
+
43
+| Requirement | Status | Action |
44
+|------------|--------|--------|
45
+| NSMicrophoneUsageDescription | PASS | - |
46
+| NSCameraUsageDescription | PASS | - |
47
+| NSPhotoLibraryUsageDescription | PASS | - |
48
+| NSLocalNetworkUsageDescription | PASS | - |
49
+| NSBonjourServices | PASS | Fixed - removed _http._tcp |
50
+| NSAppTransportSecurity | PASS | Fixed - removed NSAllowsArbitraryLoads |
51
+| UIBackgroundModes: audio | PASS | - |
52
+| Privacy Policy | FAIL | Fix L2 |
53
+| PrivacyInfo.xcprivacy | FAIL | Fix L1 |
54
+| TLS for network | PASS | Fixed C2 - self-signed cert, onBadCertificate=true |
ios/Podfile.lock
....@@ -2,6 +2,9 @@
22 - audioplayers_darwin (0.0.1):
33 - Flutter
44 - FlutterMacOS
5
+ - bonsoir_darwin (0.0.1):
6
+ - Flutter
7
+ - FlutterMacOS
58 - device_info_plus (0.0.1):
69 - Flutter
710 - DKImagePickerController/Core (4.3.9):
....@@ -43,8 +46,6 @@
4346 - Flutter
4447 - image_picker_ios (0.0.1):
4548 - Flutter
46
- - package_info_plus (0.4.5):
47
- - Flutter
4849 - permission_handler_apple (9.3.0):
4950 - Flutter
5051 - record_ios (1.2.0):
....@@ -60,23 +61,20 @@
6061 - SwiftyGif (5.4.5)
6162 - vibration (1.7.5):
6263 - Flutter
63
- - wakelock_plus (0.0.1):
64
- - Flutter
6564
6665 DEPENDENCIES:
6766 - audioplayers_darwin (from `.symlinks/plugins/audioplayers_darwin/darwin`)
67
+ - bonsoir_darwin (from `.symlinks/plugins/bonsoir_darwin/darwin`)
6868 - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
6969 - file_picker (from `.symlinks/plugins/file_picker/ios`)
7070 - Flutter (from `Flutter`)
7171 - flutter_secure_storage (from `.symlinks/plugins/flutter_secure_storage/ios`)
7272 - image_picker_ios (from `.symlinks/plugins/image_picker_ios/ios`)
73
- - package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
7473 - permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
7574 - record_ios (from `.symlinks/plugins/record_ios/ios`)
7675 - share_plus (from `.symlinks/plugins/share_plus/ios`)
7776 - shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
7877 - vibration (from `.symlinks/plugins/vibration/ios`)
79
- - wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
8078
8179 SPEC REPOS:
8280 trunk:
....@@ -88,6 +86,8 @@
8886 EXTERNAL SOURCES:
8987 audioplayers_darwin:
9088 :path: ".symlinks/plugins/audioplayers_darwin/darwin"
89
+ bonsoir_darwin:
90
+ :path: ".symlinks/plugins/bonsoir_darwin/darwin"
9191 device_info_plus:
9292 :path: ".symlinks/plugins/device_info_plus/ios"
9393 file_picker:
....@@ -98,8 +98,6 @@
9898 :path: ".symlinks/plugins/flutter_secure_storage/ios"
9999 image_picker_ios:
100100 :path: ".symlinks/plugins/image_picker_ios/ios"
101
- package_info_plus:
102
- :path: ".symlinks/plugins/package_info_plus/ios"
103101 permission_handler_apple:
104102 :path: ".symlinks/plugins/permission_handler_apple/ios"
105103 record_ios:
....@@ -110,11 +108,10 @@
110108 :path: ".symlinks/plugins/shared_preferences_foundation/darwin"
111109 vibration:
112110 :path: ".symlinks/plugins/vibration/ios"
113
- wakelock_plus:
114
- :path: ".symlinks/plugins/wakelock_plus/ios"
115111
116112 SPEC CHECKSUMS:
117113 audioplayers_darwin: 835ced6edd4c9fc8ebb0a7cc9e294a91d99917d5
114
+ bonsoir_darwin: 29c7ccf356646118844721f36e1de4b61f6cbd0e
118115 device_info_plus: 21fcca2080fbcd348be798aa36c3e5ed849eefbe
119116 DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
120117 DKPhotoGallery: b3834fecb755ee09a593d7c9e389d8b5d6deed60
....@@ -122,7 +119,6 @@
122119 Flutter: cabc95a1d2626b1b06e7179b784ebcf0c0cde467
123120 flutter_secure_storage: 1ed9476fba7e7a782b22888f956cce43e2c62f13
124121 image_picker_ios: e0ece4aa2a75771a7de3fa735d26d90817041326
125
- package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
126122 permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
127123 record_ios: 412daca2350b228e698fffcd08f1f94ceb1e3844
128124 SDWebImage: e9fc87c1aab89a8ab1bbd74eba378c6f53be8abf
....@@ -130,7 +126,6 @@
130126 shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
131127 SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4
132128 vibration: 8e2f50fc35bb736f9eecb7dd9f7047fbb6a6e888
133
- wakelock_plus: e29112ab3ef0b318e58cfa5c32326458be66b556
134129
135130 PODFILE CHECKSUM: 3c63482e143d1b91d2d2560aee9fb04ecc74ac7e
136131
ios/Runner/Info.plist
....@@ -61,8 +61,6 @@
6161 <string>PAILot needs camera access to take photos</string>
6262 <key>NSAppTransportSecurity</key>
6363 <dict>
64
- <key>NSAllowsArbitraryLoads</key>
65
- <true/>
6664 <key>NSAllowsLocalNetworking</key>
6765 <true/>
6866 </dict>
....@@ -70,7 +68,6 @@
7068 <string>PAILot needs local network access to discover and connect to AIBroker</string>
7169 <key>NSBonjourServices</key>
7270 <array>
73
- <string>_http._tcp</string>
7471 <string>_mqtt._tcp</string>
7572 </array>
7673 <key>UISupportedInterfaceOrientations</key>
lib/providers/providers.dart
....@@ -39,7 +39,9 @@
3939 if (json != null) {
4040 state = ServerConfig.fromJson(jsonDecode(json) as Map<String, dynamic>);
4141 }
42
- } catch (_) {}
42
+ } catch (e) {
43
+ debugPrint('ServerConfig load failed: $e');
44
+ }
4345 }
4446
4547 Future<void> save(ServerConfig config) async {
....@@ -58,6 +60,8 @@
5860 final wsStatusProvider =
5961 StateProvider<ConnectionStatus>((ref) => ConnectionStatus.disconnected);
6062
63
+final connectionDetailProvider = StateProvider<String>((ref) => '');
64
+
6165 // --- Sessions ---
6266
6367 final sessionsProvider = StateProvider<List<Session>>((ref) => []);
lib/screens/chat_screen.dart
....@@ -1,6 +1,8 @@
1
+import 'dart:async';
12 import 'dart:convert';
23 import 'dart:io';
34
5
+import 'package:flutter/foundation.dart';
46 import 'package:path_provider/path_provider.dart';
57
68 import 'package:flutter/material.dart';
....@@ -36,6 +38,8 @@
3638 }
3739
3840 Future<void> _chatLog(String msg) async {
41
+ debugPrint('[Chat] $msg');
42
+ if (!kDebugMode) return;
3943 try {
4044 final dir = await getApplicationDocumentsDirectory();
4145 final file = File('${dir.path}/mqtt_debug.log');
....@@ -60,6 +64,7 @@
6064 final List<Map<String, dynamic>> _pendingMessages = [];
6165 final Map<String, List<Message>> _catchUpPending = {};
6266 List<String>? _cachedSessionOrder;
67
+ Timer? _typingTimer;
6368
6469 @override
6570 void initState() {
....@@ -129,10 +134,13 @@
129134 }
130135 }
131136
137
+ bool _isLoadingMore = false;
132138 void _onScroll() {
133
- if (_scrollController.position.pixels >=
134
- _scrollController.position.maxScrollExtent - 100) {
135
- ref.read(messagesProvider.notifier).loadMore();
139
+ if (!_isLoadingMore &&
140
+ _scrollController.position.pixels >=
141
+ _scrollController.position.maxScrollExtent - 100) {
142
+ _isLoadingMore = true;
143
+ ref.read(messagesProvider.notifier).loadMore().then((_) => _isLoadingMore = false);
136144 }
137145 }
138146
....@@ -160,6 +168,14 @@
160168 _ws!.onStatusChanged = (status) {
161169 if (mounted) {
162170 ref.read(wsStatusProvider.notifier).state = status;
171
+ if (status == ConnectionStatus.connected) {
172
+ ref.read(connectionDetailProvider.notifier).state = '';
173
+ }
174
+ }
175
+ };
176
+ _ws!.onStatusDetail = (detail) {
177
+ if (mounted) {
178
+ ref.read(connectionDetailProvider.notifier).state = detail;
163179 }
164180 };
165181 _ws!.onMessage = _handleMessage;
....@@ -236,6 +252,15 @@
236252 // Strict: only show typing for the ACTIVE session, ignore all others
237253 if (activeId != null && typingSession == activeId) {
238254 ref.read(isTypingProvider.notifier).state = typing;
255
+ // Auto-clear after 10s in case typing_end is missed
256
+ if (typing) {
257
+ _typingTimer?.cancel();
258
+ _typingTimer = Timer(const Duration(seconds: 10), () {
259
+ if (mounted) ref.read(isTypingProvider.notifier).state = false;
260
+ });
261
+ } else {
262
+ _typingTimer?.cancel();
263
+ }
239264 }
240265 case 'typing_end':
241266 final endSession = msg['sessionId'] as String?;
....@@ -1301,6 +1326,7 @@
13011326 final messages = ref.watch(messagesProvider);
13021327 final wsStatus = ref.watch(wsStatusProvider);
13031328 final isTyping = ref.watch(isTypingProvider);
1329
+ final connectionDetail = ref.watch(connectionDetailProvider);
13041330 final sessions = ref.watch(sessionsProvider);
13051331 final activeSession = ref.watch(activeSessionProvider);
13061332 final unreadCounts = ref.watch(unreadCountsProvider);
....@@ -1319,9 +1345,20 @@
13191345 _scaffoldKey.currentState?.openDrawer();
13201346 },
13211347 ),
1322
- title: Text(
1323
- activeSession?.name ?? 'PAILot',
1324
- style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
1348
+ title: Column(
1349
+ crossAxisAlignment: CrossAxisAlignment.center,
1350
+ mainAxisSize: MainAxisSize.min,
1351
+ children: [
1352
+ Text(
1353
+ activeSession?.name ?? 'PAILot',
1354
+ style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w600),
1355
+ ),
1356
+ if (connectionDetail.isNotEmpty && wsStatus != ConnectionStatus.connected)
1357
+ Text(
1358
+ connectionDetail,
1359
+ style: TextStyle(fontSize: 11, color: Colors.grey.shade400),
1360
+ ),
1361
+ ],
13251362 ),
13261363 actions: [
13271364 StatusDot(status: wsStatus),
lib/screens/settings_screen.dart
....@@ -1,5 +1,6 @@
11 import 'package:flutter/material.dart';
22 import 'package:flutter_riverpod/flutter_riverpod.dart';
3
+import 'package:shared_preferences/shared_preferences.dart';
34
45 import '../models/server_config.dart';
56 import '../providers/providers.dart';
....@@ -157,6 +158,11 @@
157158 hintText: '192.168.1.100',
158159 ),
159160 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
+ },
160166 ),
161167 const SizedBox(height: 16),
162168
....@@ -170,6 +176,11 @@
170176 hintText: '10.8.0.1 (OpenVPN static IP)',
171177 ),
172178 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
+ },
173184 ),
174185 const SizedBox(height: 16),
175186
....@@ -197,6 +208,14 @@
197208 hintText: '8765',
198209 ),
199210 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
+ },
200219 ),
201220 const SizedBox(height: 16),
202221
....@@ -209,6 +228,14 @@
209228 decoration: const InputDecoration(
210229 hintText: 'AA:BB:CC:DD:EE:FF',
211230 ),
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
+ },
212239 ),
213240 const SizedBox(height: 16),
214241
....@@ -249,6 +276,44 @@
249276 label: const Text('Wake Mac'),
250277 ),
251278 const SizedBox(height: 12),
279
+
280
+ // Reset TLS Trust button
281
+ OutlinedButton.icon(
282
+ onPressed: () async {
283
+ final confirmed = await showDialog<bool>(
284
+ context: context,
285
+ builder: (ctx) => AlertDialog(
286
+ title: const Text('Reset Server Trust?'),
287
+ content: const Text(
288
+ 'This clears the saved server certificate fingerprint. '
289
+ 'Use this if you reinstalled AIBroker or changed servers. '
290
+ 'The app will trust the next server it connects to.',
291
+ ),
292
+ actions: [
293
+ TextButton(
294
+ onPressed: () => Navigator.pop(ctx, false),
295
+ child: const Text('Cancel'),
296
+ ),
297
+ TextButton(
298
+ onPressed: () => Navigator.pop(ctx, true),
299
+ child: const Text('Reset', style: TextStyle(color: AppColors.error)),
300
+ ),
301
+ ],
302
+ ),
303
+ );
304
+ if (confirmed == true && mounted) {
305
+ // Access MqttService through the provider and reset trust
306
+ final prefs = await SharedPreferences.getInstance();
307
+ await prefs.remove('trustedCertFingerprint');
308
+ ScaffoldMessenger.of(context).showSnackBar(
309
+ const SnackBar(content: Text('Server trust reset. Reconnect to trust the new server.')),
310
+ );
311
+ }
312
+ },
313
+ icon: const Icon(Icons.shield_outlined),
314
+ label: const Text('Reset Server Trust'),
315
+ ),
316
+ const SizedBox(height: 12),
252317 ],
253318 ),
254319 ),
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
....@@ -1,8 +1,11 @@
11 import 'dart:async';
22 import 'dart:convert';
33 import 'dart:io';
4
+import 'dart:typed_data';
5
+import 'package:crypto/crypto.dart';
46
57 import 'package:bonsoir/bonsoir.dart';
8
+import 'package:flutter/foundation.dart';
69 import 'package:flutter/widgets.dart';
710 import 'package:path_provider/path_provider.dart' as pp;
811 import 'package:mqtt_client/mqtt_client.dart';
....@@ -21,8 +24,10 @@
2124 reconnecting,
2225 }
2326
24
-// Debug log to file (survives release builds)
27
+// Debug log — writes to file only in debug builds, always prints via debugPrint
2528 Future<void> _mqttLog(String msg) async {
29
+ debugPrint('[MQTT] $msg');
30
+ if (!kDebugMode) return;
2631 try {
2732 final dir = await pp.getApplicationDocumentsDirectory();
2833 final file = File('${dir.path}/mqtt_debug.log');
....@@ -44,6 +49,7 @@
4449 ConnectionStatus _status = ConnectionStatus.disconnected;
4550 bool _intentionalClose = false;
4651 String? _clientId;
52
+ String? _lastDiscoveredHost;
4753 StreamSubscription? _updatesSub;
4854
4955 // Message deduplication
....@@ -53,6 +59,7 @@
5359
5460 // Callbacks
5561 void Function(ConnectionStatus status)? onStatusChanged;
62
+ void Function(String detail)? onStatusDetail; // "Probing local...", "Scanning network..."
5663 void Function(Map<String, dynamic> message)? onMessage;
5764 void Function()? onOpen;
5865 void Function()? onClose;
....@@ -95,6 +102,9 @@
95102 _intentionalClose = false;
96103 _setStatus(ConnectionStatus.connecting);
97104
105
+ // Load trusted cert fingerprint for TOFU verification
106
+ if (_trustedFingerprint == null) await _loadTrustedFingerprint();
107
+
98108 // Send Wake-on-LAN if MAC configured
99109 if (config.macAddress != null && config.macAddress!.isNotEmpty) {
100110 try {
....@@ -104,49 +114,51 @@
104114
105115 final clientId = await _getClientId();
106116
107
- // Connection order: local → Bonjour → VPN → remote
108
- final attempts = <MapEntry<String, int>>[]; // host → timeout ms
109
- if (config.localHost != null && config.localHost!.isNotEmpty) {
110
- attempts.add(MapEntry(config.localHost!, 2500));
111
- }
112
- // Bonjour placeholder — inserted dynamically below
113
- if (config.vpnHost != null && config.vpnHost!.isNotEmpty) {
114
- attempts.add(MapEntry(config.vpnHost!, 3000));
115
- }
116
- if (config.host.isNotEmpty) {
117
- attempts.add(MapEntry(config.host, 5000));
118
- }
119
- _mqttLog('MQTT: attempts=${attempts.map((e) => e.key).join(", ")} port=${config.port}');
117
+ // Probe all hosts in parallel to find which one responds, then connect to the winner
118
+ final hosts = <String>{};
119
+ if (config.localHost != null && config.localHost!.isNotEmpty) hosts.add(config.localHost!);
120
+ if (_lastDiscoveredHost != null) hosts.add(_lastDiscoveredHost!);
121
+ if (config.vpnHost != null && config.vpnHost!.isNotEmpty) hosts.add(config.vpnHost!);
122
+ if (config.host.isNotEmpty) hosts.add(config.host);
123
+ _mqttLog('MQTT: probing ${hosts.length} hosts in parallel: ${hosts.join(", ")}');
124
+ onStatusDetail?.call('Probing ${hosts.length} hosts...');
120125
121
- // Try configured local host first
122
- for (final attempt in attempts) {
123
- if (_intentionalClose) return;
124
- _mqttLog('MQTT: trying ${attempt.key}:${config.port}');
125
- try {
126
- if (await _tryConnect(attempt.key, clientId, timeout: attempt.value)) return;
127
- } catch (e) {
128
- _mqttLog('MQTT: ${attempt.key} error=$e');
129
- }
130
-
131
- // After local host fails, try Bonjour discovery before VPN/remote
132
- if (attempt.key == config.localHost && !_intentionalClose) {
133
- _mqttLog('MQTT: trying Bonjour discovery...');
134
- final bonjourHost = await _discoverViaMdns();
135
- if (bonjourHost != null && !_intentionalClose) {
136
- _mqttLog('MQTT: Bonjour found $bonjourHost');
137
- try {
138
- if (await _tryConnect(bonjourHost, clientId, timeout: 3000)) return;
139
- } catch (e) {
140
- _mqttLog('MQTT: Bonjour host $bonjourHost error=$e');
126
+ // Probe all configured hosts in parallel — first to respond wins
127
+ String? winner;
128
+ if (hosts.isNotEmpty) {
129
+ final probes = hosts.map((h) => _probeHost(h, config.port)).toList();
130
+ // Also start discovery in parallel
131
+ if (_lastDiscoveredHost == null) {
132
+ probes.add(() async {
133
+ final discovered = await _discoverViaMdns();
134
+ if (discovered != null) {
135
+ _lastDiscoveredHost = discovered;
136
+ return discovered;
141137 }
142
- } else {
143
- _mqttLog('MQTT: Bonjour discovery returned nothing');
144
- }
138
+ return null;
139
+ }());
140
+ }
141
+ final results = await Future.wait(probes);
142
+ winner = results.firstWhere((r) => r != null, orElse: () => null);
143
+ } else if (_lastDiscoveredHost == null) {
144
+ // No configured hosts — try discovery only
145
+ winner = await _discoverViaMdns();
146
+ if (winner != null) _lastDiscoveredHost = winner;
147
+ }
148
+
149
+ if (winner != null && !_intentionalClose) {
150
+ _mqttLog('MQTT: probe winner: $winner, connecting...');
151
+ onStatusDetail?.call('Connecting to $winner...');
152
+ try {
153
+ if (await _tryConnect(winner, clientId, timeout: 5000)) return;
154
+ } catch (e) {
155
+ _mqttLog('MQTT: connect to $winner failed: $e');
145156 }
146157 }
147158
148159 // All hosts failed — retry after delay
149160 _mqttLog('MQTT: all attempts failed, retrying in 5s');
161
+ onStatusDetail?.call('No server found, retrying...');
150162 _setStatus(ConnectionStatus.reconnecting);
151163 Future.delayed(const Duration(seconds: 5), () {
152164 if (!_intentionalClose && _status != ConnectionStatus.connected) {
....@@ -156,8 +168,10 @@
156168 }
157169
158170 /// Discover AIBroker on local network via Bonjour/mDNS.
171
+ /// Falls back to subnet scan if Bonjour fails (iOS blocks mDNS on Personal Hotspot).
159172 /// Returns the IP address or null if not found within timeout.
160173 Future<String?> _discoverViaMdns({Duration timeout = const Duration(seconds: 3)}) async {
174
+ // Try Bonjour first
161175 try {
162176 final discovery = BonsoirDiscovery(type: '_mqtt._tcp');
163177 await discovery.initialize();
....@@ -187,9 +201,125 @@
187201 await sub?.cancel();
188202 await discovery.stop();
189203
190
- return ip;
204
+ if (ip != null) return ip;
191205 } catch (e) {
192206 _mqttLog('MQTT: Bonjour discovery error: $e');
207
+ }
208
+
209
+ // Fallback: scan local subnet for MQTT port (handles Personal Hotspot)
210
+ _mqttLog('MQTT: Bonjour failed, trying subnet scan...');
211
+ onStatusDetail?.call('Scanning local network...');
212
+ return _scanSubnetForMqtt();
213
+ }
214
+
215
+ /// Scan the local subnet for an MQTT broker by probing the configured port.
216
+ /// Useful when iOS Personal Hotspot blocks mDNS.
217
+ Future<String?> _scanSubnetForMqtt() async {
218
+ try {
219
+ // Get device's own IP to determine the subnet
220
+ final interfaces = await NetworkInterface.list(type: InternetAddressType.IPv4);
221
+ for (final iface in interfaces) {
222
+ for (final addr in iface.addresses) {
223
+ final parts = addr.address.split('.');
224
+ if (parts.length != 4) continue;
225
+ // Skip loopback
226
+ if (parts[0] == '127') continue;
227
+ // Only scan small subnets (hotspot = /28, max 14 hosts)
228
+ final subnet = '${parts[0]}.${parts[1]}.${parts[2]}';
229
+ _mqttLog('MQTT: scanning $subnet.0/24 on ${iface.name}');
230
+
231
+ // Probe in batches of 20 to avoid flooding the network.
232
+ // Early exit on first hit.
233
+ for (int batch = 1; batch <= 254; batch += 20) {
234
+ final end = (batch + 19).clamp(1, 254);
235
+ final futures = <Future<String?>>[];
236
+ for (int i = batch; i <= end; i++) {
237
+ final probe = '$subnet.$i';
238
+ if (probe == addr.address) continue;
239
+ futures.add(_probeHost(probe, config.port));
240
+ }
241
+ final results = await Future.wait(futures);
242
+ final found = results.firstWhere((r) => r != null, orElse: () => null);
243
+ if (found != null) {
244
+ _mqttLog('MQTT: subnet scan found broker at $found');
245
+ return found;
246
+ }
247
+ }
248
+ }
249
+ }
250
+ } catch (e) {
251
+ _mqttLog('MQTT: subnet scan error: $e');
252
+ }
253
+ return null;
254
+ }
255
+
256
+ // --- TOFU (Trust On First Use) certificate pinning ---
257
+
258
+ String? _trustedFingerprint; // Loaded from SharedPreferences at startup
259
+
260
+ /// Load the trusted cert fingerprint from storage.
261
+ Future<void> _loadTrustedFingerprint() async {
262
+ final prefs = await SharedPreferences.getInstance();
263
+ _trustedFingerprint = prefs.getString('trustedCertFingerprint');
264
+ if (_trustedFingerprint != null) {
265
+ _mqttLog('TOFU: loaded trusted fingerprint: ${_trustedFingerprint!.substring(0, 16)}...');
266
+ }
267
+ }
268
+
269
+ /// Compute SHA-256 fingerprint of a certificate's DER bytes.
270
+ String _certFingerprint(X509Certificate cert) {
271
+ final der = cert.der;
272
+ final digest = sha256.convert(der);
273
+ return digest.toString();
274
+ }
275
+
276
+ /// TOFU verification: accept on first use, reject if fingerprint changes.
277
+ bool _verifyCertTofu(dynamic certificate) {
278
+ if (certificate is! X509Certificate) return true; // Can't verify, accept
279
+
280
+ final fingerprint = _certFingerprint(certificate);
281
+
282
+ if (_trustedFingerprint == null) {
283
+ // First connection — trust and save
284
+ _trustedFingerprint = fingerprint;
285
+ SharedPreferences.getInstance().then((prefs) {
286
+ prefs.setString('trustedCertFingerprint', fingerprint);
287
+ });
288
+ _mqttLog('TOFU: first connection, saved fingerprint: ${fingerprint.substring(0, 16)}...');
289
+ return true;
290
+ }
291
+
292
+ if (_trustedFingerprint == fingerprint) {
293
+ return true; // Known cert, trusted
294
+ }
295
+
296
+ // Fingerprint mismatch — possible MITM or server reinstall
297
+ _mqttLog('TOFU: CERT MISMATCH! Expected ${_trustedFingerprint!.substring(0, 16)}... got ${fingerprint.substring(0, 16)}...');
298
+ // Reject the connection. User must reset trust in settings.
299
+ return false;
300
+ }
301
+
302
+ /// Reset the trusted cert fingerprint (e.g., after server reinstall).
303
+ Future<void> resetTrustedCert() async {
304
+ _trustedFingerprint = null;
305
+ final prefs = await SharedPreferences.getInstance();
306
+ await prefs.remove('trustedCertFingerprint');
307
+ _mqttLog('TOFU: trust reset');
308
+ }
309
+
310
+ /// Probe a single host:port with a TLS connection attempt (1s timeout).
311
+ /// Uses SecureSocket since the broker now requires TLS.
312
+ Future<String?> _probeHost(String host, int port) async {
313
+ try {
314
+ final socket = await SecureSocket.connect(
315
+ host,
316
+ port,
317
+ timeout: const Duration(seconds: 1),
318
+ onBadCertificate: (_) => true, // Accept self-signed cert during scan
319
+ );
320
+ await socket.close();
321
+ return host;
322
+ } catch (_) {
193323 return null;
194324 }
195325 }
....@@ -203,6 +333,15 @@
203333 // client.maxConnectionAttempts is final — can't set it
204334 client.logging(on: false);
205335
336
+ // TLS with TOFU (Trust On First Use) cert pinning.
337
+ // First connection: accept cert, save its SHA-256 fingerprint.
338
+ // Future connections: only accept certs matching the saved fingerprint.
339
+ client.secure = true;
340
+ client.securityContext = SecurityContext(withTrustedRoots: true);
341
+ client.onBadCertificate = (dynamic certificate) {
342
+ return _verifyCertTofu(certificate);
343
+ };
344
+
206345 client.onConnected = _onConnected;
207346 client.onDisconnected = _onDisconnected;
208347 client.onAutoReconnect = _onAutoReconnect;
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 }
macos/Flutter/GeneratedPluginRegistrant.swift
....@@ -6,25 +6,23 @@
66 import Foundation
77
88 import audioplayers_darwin
9
+import bonsoir_darwin
910 import device_info_plus
1011 import file_picker
1112 import file_selector_macos
1213 import flutter_secure_storage_macos
13
-import package_info_plus
1414 import record_macos
1515 import share_plus
1616 import shared_preferences_foundation
17
-import wakelock_plus
1817
1918 func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
2019 AudioplayersDarwinPlugin.register(with: registry.registrar(forPlugin: "AudioplayersDarwinPlugin"))
20
+ SwiftBonsoirPlugin.register(with: registry.registrar(forPlugin: "SwiftBonsoirPlugin"))
2121 DeviceInfoPlusMacosPlugin.register(with: registry.registrar(forPlugin: "DeviceInfoPlusMacosPlugin"))
2222 FilePickerPlugin.register(with: registry.registrar(forPlugin: "FilePickerPlugin"))
2323 FileSelectorPlugin.register(with: registry.registrar(forPlugin: "FileSelectorPlugin"))
2424 FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin"))
25
- FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin"))
2625 RecordMacOsPlugin.register(with: registry.registrar(forPlugin: "RecordMacOsPlugin"))
2726 SharePlusMacosPlugin.register(with: registry.registrar(forPlugin: "SharePlusMacosPlugin"))
2827 SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
29
- WakelockPlusMacosPlugin.register(with: registry.registrar(forPlugin: "WakelockPlusMacosPlugin"))
3028 }
pubspec.lock
....@@ -170,7 +170,7 @@
170170 source: hosted
171171 version: "0.3.5+2"
172172 crypto:
173
- dependency: transitive
173
+ dependency: "direct main"
174174 description:
175175 name: crypto
176176 sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
....@@ -608,22 +608,6 @@
608608 url: "https://pub.dev"
609609 source: hosted
610610 version: "9.3.0"
611
- package_info_plus:
612
- dependency: transitive
613
- description:
614
- name: package_info_plus
615
- sha256: f69da0d3189a4b4ceaeb1a3defb0f329b3b352517f52bed4290f83d4f06bc08d
616
- url: "https://pub.dev"
617
- source: hosted
618
- version: "9.0.0"
619
- package_info_plus_platform_interface:
620
- dependency: transitive
621
- description:
622
- name: package_info_plus_platform_interface
623
- sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086"
624
- url: "https://pub.dev"
625
- source: hosted
626
- version: "3.2.1"
627611 path:
628612 dependency: transitive
629613 description:
....@@ -1069,22 +1053,6 @@
10691053 url: "https://pub.dev"
10701054 source: hosted
10711055 version: "15.0.2"
1072
- wakelock_plus:
1073
- dependency: "direct main"
1074
- description:
1075
- name: wakelock_plus
1076
- sha256: "8b12256f616346910c519a35606fb69b1fe0737c06b6a447c6df43888b097f39"
1077
- url: "https://pub.dev"
1078
- source: hosted
1079
- version: "1.5.1"
1080
- wakelock_plus_platform_interface:
1081
- dependency: transitive
1082
- description:
1083
- name: wakelock_plus_platform_interface
1084
- sha256: "24b84143787220a403491c2e5de0877fbbb87baf3f0b18a2a988973863db4b03"
1085
- url: "https://pub.dev"
1086
- source: hosted
1087
- version: "1.4.0"
10881056 web:
10891057 dependency: transitive
10901058 description:
....@@ -1093,22 +1061,6 @@
10931061 url: "https://pub.dev"
10941062 source: hosted
10951063 version: "1.1.1"
1096
- web_socket:
1097
- dependency: transitive
1098
- description:
1099
- name: web_socket
1100
- sha256: "34d64019aa8e36bf9842ac014bb5d2f5586ca73df5e4d9bf5c936975cae6982c"
1101
- url: "https://pub.dev"
1102
- source: hosted
1103
- version: "1.0.1"
1104
- web_socket_channel:
1105
- dependency: "direct main"
1106
- description:
1107
- name: web_socket_channel
1108
- sha256: d645757fb0f4773d602444000a8131ff5d48c9e47adfe9772652dd1a4f2d45c8
1109
- url: "https://pub.dev"
1110
- source: hosted
1111
- version: "3.0.3"
11121064 win32:
11131065 dependency: transitive
11141066 description:
pubspec.yaml
....@@ -13,7 +13,6 @@
1313 flutter_riverpod: ^2.6.1
1414 riverpod_annotation: ^2.6.1
1515 go_router: ^14.8.1
16
- web_socket_channel: ^3.0.2
1716 path_provider: ^2.1.0
1817 shared_preferences: ^2.5.3
1918 record: ^6.2.0
....@@ -21,7 +20,6 @@
2120 permission_handler: ^11.4.0
2221 image_picker: ^1.1.2
2322 flutter_secure_storage: ^9.2.4
24
- wakelock_plus: ^1.2.8
2523 vibration: ^2.0.1
2624 share_plus: ^12.0.1
2725 udp: ^5.0.3
....@@ -32,6 +30,7 @@
3230 file_picker: ^10.3.10
3331 flutter_markdown: ^0.7.7+1
3432 bonsoir: ^6.0.2
33
+ crypto: ^3.0.7
3534
3635 dev_dependencies:
3736 flutter_test:
windows/flutter/generated_plugin_registrant.cc
....@@ -7,6 +7,7 @@
77 #include "generated_plugin_registrant.h"
88
99 #include <audioplayers_windows/audioplayers_windows_plugin.h>
10
+#include <bonsoir_windows/bonsoir_windows_plugin_c_api.h>
1011 #include <file_selector_windows/file_selector_windows.h>
1112 #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
1213 #include <permission_handler_windows/permission_handler_windows_plugin.h>
....@@ -17,6 +18,8 @@
1718 void RegisterPlugins(flutter::PluginRegistry* registry) {
1819 AudioplayersWindowsPluginRegisterWithRegistrar(
1920 registry->GetRegistrarForPlugin("AudioplayersWindowsPlugin"));
21
+ BonsoirWindowsPluginCApiRegisterWithRegistrar(
22
+ registry->GetRegistrarForPlugin("BonsoirWindowsPluginCApi"));
2023 FileSelectorWindowsRegisterWithRegistrar(
2124 registry->GetRegistrarForPlugin("FileSelectorWindows"));
2225 FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
windows/flutter/generated_plugins.cmake
....@@ -4,6 +4,7 @@
44
55 list(APPEND FLUTTER_PLUGIN_LIST
66 audioplayers_windows
7
+ bonsoir_windows
78 file_selector_windows
89 flutter_secure_storage_windows
910 permission_handler_windows