| lib/services/message_store.dart | patch | view | blame | history | |
| lib/widgets/message_bubble.dart | patch | view | blame | history | |
| pubspec.yaml | patch | view | blame | history |
lib/services/message_store.dart
.. .. @@ -88,19 +88,32 @@ 88 88 } 89 89 } 90 90 91 + /// Session id, without decoding the message it belongs to.92 + ///93 + /// The index needs ONE short field per line. jsonDecode gave it by parsing94 + /// the whole record — including an inline base64 attachment that can be tens95 + /// of megabytes — so building the index cost the size of the entire history.96 + /// With enough of it, startup exceeded the iOS watchdog and the app could97 + /// never open again: 9.5 seconds of CPU, killed at 0x8BADF00D, on a store98 + /// that had grown a few megabytes at a time for weeks.99 + ///100 + /// A line is written by us and always carries "sessionId":"…", so reading it101 + /// is a scan, not a parse.102 + static final RegExp _sessionIdPattern = RegExp(r'"sessionId"\s*:\s*"([^"]+)"');103 +104 + static String? _sessionIdOf(String line) =>105 + _sessionIdPattern.firstMatch(line)?.group(1);106 +91 107 static Future<void> _rebuildIndex(File logFile) async { 92 108 _index.clear(); 93 109 final lines = logFile.readAsLinesSync(); 94 110 for (var i = 0; i < lines.length; i++) { 95 111 final line = lines[i].trim(); 96 112 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 (_) {}113 + final sessionId = _sessionIdOf(line);114 + if (sessionId != null) {115 + _index.putIfAbsent(sessionId, () => []).add(i);116 + }104 117 } 105 118 } 106 119 .. .. @@ -189,6 +202,9 @@ 189 202 190 203 // ------------------------------------------------------------- compact -- 191 204 205 + /// Above this, compact regardless of how few lines there are.206 + static const int _compactAboveBytes = 8 * 1024 * 1024;207 +192 208 /// Rewrite the log keeping at most [keepPerSession] messages per session. 193 209 /// Called once on startup after initialize(). NOT called during normal use. 194 210 static Future<void> compact({int keepPerSession = 200}) async { .. .. @@ -198,7 +214,12 @@ 198 214 if (!logFile.existsSync()) return; 199 215 200 216 final allLines = logFile.readAsLinesSync(); 201 - if (allLines.length < 500) return; // nothing worth compacting217 + // Compact on SIZE as well as line count. A store is bounded at 200218 + // messages per session, which bounds nothing when a message carries an219 + // inline attachment: 200 screenshots is tens of megabytes, and the count220 + // never trips the threshold. Bytes are what startup has to read.221 + final bytes = logFile.lengthSync();222 + if (allLines.length < 500 && bytes < _compactAboveBytes) return;202 223 203 224 // Build a set of line numbers to keep: last keepPerSession per session. 204 225 final keepLines = <int>{}; .. .. @@ -224,14 +245,13 @@ 224 245 final line = allLines[i].trim(); 225 246 if (line.isEmpty) continue; 226 247 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 (_) {}248 + // Same scan as the index build, for the same reason: the only field249 + // needed here is short, and decoding the record to reach it makes250 + // compaction cost the size of the history it exists to bound.251 + final sid = _sessionIdOf(line);252 + if (sid != null) {253 + newIndex.putIfAbsent(sid, () => []).add(newLine);254 + }235 255 newLine++; 236 256 } 237 257 lib/widgets/message_bubble.dart
.. .. @@ -468,8 +468,7 @@ 468 468 // Other files: save to temp and share 469 469 final ext = _mimeToExt(mime); 470 470 final dir = await getTemporaryDirectory(); 471 - final fileName =472 - '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';471 + final fileName = _fileNameFor(message.content, ext);473 472 final file = File('${dir.path}/$fileName'); 474 473 await file.writeAsBytes(bytes); 475 474 .. .. @@ -483,6 +482,23 @@ 483 482 ).showSnackBar(SnackBar(content: Text('Could not open file: $e'))); 484 483 } 485 484 } 485 + }486 +487 + /// The name to save an incoming file under.488 + ///489 + /// The caption is normally the sender's own filename, so it already carries490 + /// an extension. Appending another unconditionally produced `clip.mp4.mp4` —491 + /// and where the caption had been reduced to a trailing dot, `clip..bin`,492 + /// which no player would open until it was renamed by hand.493 + ///494 + /// So: sanitise, drop trailing dots, and add the extension only when the name495 + /// does not already end in it.496 + String _fileNameFor(String caption, String ext) {497 + var base = caption.replaceAll(RegExp(r'[^\w\s.-]'), '').trim();498 + base = base.replaceAll(RegExp(r'\.+$'), '').trim();499 + if (base.isEmpty) base = 'file';500 + if (base.toLowerCase().endsWith('.${ext.toLowerCase()}')) return base;501 + return '$base.$ext';486 502 } 487 503 488 504 String _mimeToExt(String mime) { .. .. @@ -503,8 +519,33 @@ 503 519 'application/xml': 'xml', 504 520 'application/zip': 'zip', 505 521 'application/gzip': 'gz', 522 + // Video and audio were absent, which is how an mp4 became a `.bin` even523 + // once the sender labelled it correctly. The hub's table is the source524 + // this mirrors.525 + 'video/mp4': 'mp4',526 + 'video/quicktime': 'mov',527 + 'video/x-msvideo': 'avi',528 + 'video/x-matroska': 'mkv',529 + 'audio/mpeg': 'mp3',530 + 'audio/wav': 'wav',531 + 'audio/ogg': 'ogg',532 + 'audio/mp4': 'm4a',533 + 'image/jpeg': 'jpg',534 + 'image/png': 'png',535 + 'image/gif': 'gif',536 + 'image/webp': 'webp',537 + 'image/svg+xml': 'svg',506 538 }; 507 - return map[mime] ?? 'bin';539 + final normalised = mime.toLowerCase().split(';').first.trim();540 + final known = map[normalised];541 + if (known != null) return known;542 + // A type this table does not carry but whose subtype names itself:543 + // image/heic -> heic. Better than calling it `bin`.544 + final subtype = normalised.split('/').length > 1545 + ? normalised.split('/')[1]546 + : '';547 + if (RegExp(r'^[a-z0-9]{2,5}$').hasMatch(subtype)) return subtype;548 + return 'bin';508 549 } 509 550 510 551 Widget _buildFooter(BuildContext context) { pubspec.yaml
.. .. @@ -1,7 +1,7 @@ 1 1 name: pailot 2 2 description: "Voice-first AI communicator" 3 3 publish_to: 'none' 4 -version: 1.1.0+24 +version: 1.1.2+45 5 6 6 environment: 7 7 sdk: ^3.11.1