From e6eb093cc63ba020799844905439f1cabfddcd3c Mon Sep 17 00:00:00 2001
From: Matthias Nott <mnott@mnsoft.org>
Date: Tue, 11 Aug 2026 01:30:57 +0200
Subject: [PATCH] fix: a store that cannot brick itself on its own history

---
 lib/services/message_store.dart |   52 ++++++++++++++++++--------
 lib/widgets/message_bubble.dart |   47 ++++++++++++++++++++++-
 pubspec.yaml                    |    2 
 3 files changed, 81 insertions(+), 20 deletions(-)

diff --git a/lib/services/message_store.dart b/lib/services/message_store.dart
index 1497228..812a5b4 100644
--- a/lib/services/message_store.dart
+++ b/lib/services/message_store.dart
@@ -88,19 +88,32 @@
     }
   }
 
+  /// Session id, without decoding the message it belongs to.
+  ///
+  /// The index needs ONE short field per line. jsonDecode gave it by parsing
+  /// the whole record — including an inline base64 attachment that can be tens
+  /// of megabytes — so building the index cost the size of the entire history.
+  /// With enough of it, startup exceeded the iOS watchdog and the app could
+  /// never open again: 9.5 seconds of CPU, killed at 0x8BADF00D, on a store
+  /// that had grown a few megabytes at a time for weeks.
+  ///
+  /// A line is written by us and always carries "sessionId":"…", so reading it
+  /// is a scan, not a parse.
+  static final RegExp _sessionIdPattern = RegExp(r'"sessionId"\s*:\s*"([^"]+)"');
+
+  static String? _sessionIdOf(String line) =>
+      _sessionIdPattern.firstMatch(line)?.group(1);
+
   static Future<void> _rebuildIndex(File logFile) async {
     _index.clear();
     final lines = logFile.readAsLinesSync();
     for (var i = 0; i < lines.length; i++) {
       final line = lines[i].trim();
       if (line.isEmpty) continue;
-      try {
-        final map = jsonDecode(line) as Map<String, dynamic>;
-        final sessionId = map['sessionId'] as String?;
-        if (sessionId != null) {
-          _index.putIfAbsent(sessionId, () => []).add(i);
-        }
-      } catch (_) {}
+      final sessionId = _sessionIdOf(line);
+      if (sessionId != null) {
+        _index.putIfAbsent(sessionId, () => []).add(i);
+      }
     }
   }
 
@@ -189,6 +202,9 @@
 
   // ------------------------------------------------------------- compact --
 
+  /// Above this, compact regardless of how few lines there are.
+  static const int _compactAboveBytes = 8 * 1024 * 1024;
+
   /// Rewrite the log keeping at most [keepPerSession] messages per session.
   /// Called once on startup after initialize(). NOT called during normal use.
   static Future<void> compact({int keepPerSession = 200}) async {
@@ -198,7 +214,12 @@
       if (!logFile.existsSync()) return;
 
       final allLines = logFile.readAsLinesSync();
-      if (allLines.length < 500) return; // nothing worth compacting
+      // Compact on SIZE as well as line count. A store is bounded at 200
+      // messages per session, which bounds nothing when a message carries an
+      // inline attachment: 200 screenshots is tens of megabytes, and the count
+      // never trips the threshold. Bytes are what startup has to read.
+      final bytes = logFile.lengthSync();
+      if (allLines.length < 500 && bytes < _compactAboveBytes) return;
 
       // Build a set of line numbers to keep: last keepPerSession per session.
       final keepLines = <int>{};
@@ -224,14 +245,13 @@
         final line = allLines[i].trim();
         if (line.isEmpty) continue;
         buffer.write('$line\n');
-        // Extract sessionId for new index.
-        try {
-          final map = jsonDecode(line) as Map<String, dynamic>;
-          final sid = map['sessionId'] as String?;
-          if (sid != null) {
-            newIndex.putIfAbsent(sid, () => []).add(newLine);
-          }
-        } catch (_) {}
+        // Same scan as the index build, for the same reason: the only field
+        // needed here is short, and decoding the record to reach it makes
+        // compaction cost the size of the history it exists to bound.
+        final sid = _sessionIdOf(line);
+        if (sid != null) {
+          newIndex.putIfAbsent(sid, () => []).add(newLine);
+        }
         newLine++;
       }
 
diff --git a/lib/widgets/message_bubble.dart b/lib/widgets/message_bubble.dart
index bbb3c63..66e6e61 100644
--- a/lib/widgets/message_bubble.dart
+++ b/lib/widgets/message_bubble.dart
@@ -468,8 +468,7 @@
       // Other files: save to temp and share
       final ext = _mimeToExt(mime);
       final dir = await getTemporaryDirectory();
-      final fileName =
-          '${message.content.isNotEmpty ? message.content.replaceAll(RegExp(r'[^\w\s.-]'), '').trim() : 'file'}.$ext';
+      final fileName = _fileNameFor(message.content, ext);
       final file = File('${dir.path}/$fileName');
       await file.writeAsBytes(bytes);
 
@@ -483,6 +482,23 @@
         ).showSnackBar(SnackBar(content: Text('Could not open file: $e')));
       }
     }
+  }
+
+  /// The name to save an incoming file under.
+  ///
+  /// The caption is normally the sender's own filename, so it already carries
+  /// an extension. Appending another unconditionally produced `clip.mp4.mp4` —
+  /// and where the caption had been reduced to a trailing dot, `clip..bin`,
+  /// which no player would open until it was renamed by hand.
+  ///
+  /// So: sanitise, drop trailing dots, and add the extension only when the name
+  /// does not already end in it.
+  String _fileNameFor(String caption, String ext) {
+    var base = caption.replaceAll(RegExp(r'[^\w\s.-]'), '').trim();
+    base = base.replaceAll(RegExp(r'\.+$'), '').trim();
+    if (base.isEmpty) base = 'file';
+    if (base.toLowerCase().endsWith('.${ext.toLowerCase()}')) return base;
+    return '$base.$ext';
   }
 
   String _mimeToExt(String mime) {
@@ -503,8 +519,33 @@
       'application/xml': 'xml',
       'application/zip': 'zip',
       'application/gzip': 'gz',
+      // Video and audio were absent, which is how an mp4 became a `.bin` even
+      // once the sender labelled it correctly. The hub's table is the source
+      // this mirrors.
+      'video/mp4': 'mp4',
+      'video/quicktime': 'mov',
+      'video/x-msvideo': 'avi',
+      'video/x-matroska': 'mkv',
+      'audio/mpeg': 'mp3',
+      'audio/wav': 'wav',
+      'audio/ogg': 'ogg',
+      'audio/mp4': 'm4a',
+      'image/jpeg': 'jpg',
+      'image/png': 'png',
+      'image/gif': 'gif',
+      'image/webp': 'webp',
+      'image/svg+xml': 'svg',
     };
-    return map[mime] ?? 'bin';
+    final normalised = mime.toLowerCase().split(';').first.trim();
+    final known = map[normalised];
+    if (known != null) return known;
+    // A type this table does not carry but whose subtype names itself:
+    // image/heic -> heic. Better than calling it `bin`.
+    final subtype = normalised.split('/').length > 1
+        ? normalised.split('/')[1]
+        : '';
+    if (RegExp(r'^[a-z0-9]{2,5}$').hasMatch(subtype)) return subtype;
+    return 'bin';
   }
 
   Widget _buildFooter(BuildContext context) {
diff --git a/pubspec.yaml b/pubspec.yaml
index 4008653..ec16bb9 100644
--- a/pubspec.yaml
+++ b/pubspec.yaml
@@ -1,7 +1,7 @@
 name: pailot
 description: "Voice-first AI communicator"
 publish_to: 'none'
-version: 1.1.0+2
+version: 1.1.2+4
 
 environment:
   sdk: ^3.11.1

--
Gitblit v1.3.1