Matthias Nott
9 days ago 90fc31a938afac0f6910c7947c6ecba0adebfea4
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
import 'dart:async';
import 'dart:convert';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:path_provider/path_provider.dart';
import '../models/message.dart';
import 'trace_service.dart';
/// Per-session JSON file persistence with debounced saves.
class MessageStore {
  MessageStore._();
  static Directory? _baseDir;
  static Timer? _debounceTimer;
  static final Map<String, List<Message>> _pendingSaves = {};
  static const _backupChannel =
      MethodChannel('com.mnsoft.pailot/backup');
  /// Initialize the base directory for message storage.
  /// On iOS, the directory is excluded from iCloud / iTunes backup so that
  /// large base64 image attachments do not bloat the user's cloud storage.
  /// Messages can be re-fetched from the server if needed.
  static Future<Directory> _getBaseDir() async {
    if (_baseDir != null) return _baseDir!;
    final appDir = await getApplicationDocumentsDirectory();
    _baseDir = Directory('${appDir.path}/messages');
    final created = !await _baseDir!.exists();
    if (created) {
      await _baseDir!.create(recursive: true);
    }
    // Exclude from iCloud / iTunes backup (best-effort, iOS only).
    if (Platform.isIOS) {
      try {
        await _backupChannel.invokeMethod<void>(
          'excludeFromBackup',
          _baseDir!.path,
        );
      } catch (_) {
        // Non-fatal: if the channel call fails, backup exclusion is skipped.
      }
    }
    return _baseDir!;
  }
  static String _fileForSession(String sessionId) {
    // Sanitize session ID for filename
    final safe = sessionId.replaceAll(RegExp(r'[^\w\-]'), '_');
    return 'session_$safe.json';
  }
  /// Save messages for a session with 1-second debounce.
  static void save(String sessionId, List<Message> messages) {
    _pendingSaves[sessionId] = messages;
    _debounceTimer?.cancel();
    _debounceTimer = Timer(const Duration(seconds: 1), _flushAll);
  }
  /// Write directly to disk, bypassing debounce. For critical saves.
  static Future<void> writeDirect(String sessionId, List<Message> messages) async {
    // Cancel ALL pending debounce to prevent race with frozen iOS timers
    _debounceTimer?.cancel();
    _pendingSaves.remove(sessionId);
    await _writeSession(sessionId, messages);
  }
  /// Immediately flush all pending saves.
  static Future<void> flush() async {
    _debounceTimer?.cancel();
    await _flushAll();
  }
  static Future<void> _flushAll() async {
    final entries = Map<String, List<Message>>.from(_pendingSaves);
    _pendingSaves.clear();
    for (final entry in entries.entries) {
      await _writeSession(entry.key, entry.value);
    }
  }
  static Future<void> _writeSession(
      String sessionId, List<Message> messages) async {
    try {
      final dir = await _getBaseDir();
      final file = File('${dir.path}/${_fileForSession(sessionId)}');
      // Strip heavy fields for persistence
      final lightMessages = messages.map((m) => m.toJsonLight()).toList();
      final json = jsonEncode(lightMessages);
      await file.writeAsString(json);
      TraceService.instance.addTrace('MsgStore WRITE', '${sessionId.substring(0, 8)}: ${messages.length} msgs');
    } catch (e) {
      TraceService.instance.addTrace('MsgStore WRITE ERROR', '${sessionId.substring(0, 8)}: $e');
    }
  }
  /// Load messages for a session.
  /// [limit] controls how many recent messages to return (default: 50).
  /// [offset] is the number of messages to skip from the end (for pagination).
  static Future<List<Message>> load(
    String sessionId, {
    int limit = 50,
    int offset = 0,
  }) async {
    try {
      final dir = await _getBaseDir();
      final file = File('${dir.path}/${_fileForSession(sessionId)}');
      if (!await file.exists()) return [];
      final jsonStr = await file.readAsString();
      final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
      final allMessages = jsonList
          .map((j) => _messageFromJson(j as Map<String, dynamic>))
          .where((m) => !m.isEmptyVoice && !m.isEmptyText)
          .toList();
      // Paginate from the end (newest messages first in storage)
      if (offset >= allMessages.length) return [];
      final end = allMessages.length - offset;
      final start = (end - limit).clamp(0, end);
      return allMessages.sublist(start, end);
    } catch (e) {
      return [];
    }
  }
  /// Load all messages for a session (no pagination).
  static Future<List<Message>> loadAll(String sessionId) async {
    try {
      final dir = await _getBaseDir();
      final file = File('${dir.path}/${_fileForSession(sessionId)}');
      if (!await file.exists()) return [];
      final jsonStr = await file.readAsString();
      final List<dynamic> jsonList = jsonDecode(jsonStr) as List<dynamic>;
      final msgs = jsonList
          .map((j) => _messageFromJson(j as Map<String, dynamic>))
          .where((m) => !m.isEmptyVoice && !m.isEmptyText)
          .toList();
      TraceService.instance.addTrace('MsgStore LOAD', '${sessionId.substring(0, 8)}: ${msgs.length} msgs');
      return msgs;
    } catch (e) {
      TraceService.instance.addTrace('MsgStore LOAD ERROR', '${sessionId.substring(0, 8)}: $e');
      return [];
    }
  }
  /// Deserialize a message from JSON, applying migration rules:
  /// - Voice messages without audioUri are downgraded to text (transcript only).
  ///   This handles messages saved before a restart, where the temp audio file
  ///   is no longer available. The transcript (content) is preserved.
  static Message _messageFromJson(Map<String, dynamic> json) {
    final raw = Message.fromJson(json);
    if (raw.type == MessageType.voice &&
        (raw.audioUri == null || raw.audioUri!.isEmpty)) {
      // Downgrade to text so the bubble shows the transcript instead of a
      // broken play button.
      return Message(
        id: raw.id,
        role: raw.role,
        type: MessageType.text,
        content: raw.content,
        timestamp: raw.timestamp,
        status: raw.status,
        duration: raw.duration,
      );
    }
    return raw;
  }
  /// Delete stored messages for a session.
  static Future<void> delete(String sessionId) async {
    try {
      final dir = await _getBaseDir();
      final file = File('${dir.path}/${_fileForSession(sessionId)}');
      if (await file.exists()) {
        await file.delete();
      }
    } catch (_) {}
  }
  /// Clear all stored messages.
  static Future<void> clearAll() async {
    try {
      final dir = await _getBaseDir();
      if (await dir.exists()) {
        await dir.delete(recursive: true);
        await dir.create(recursive: true);
      }
    } catch (_) {}
  }
}