@@ -11,14 +11,30 @@ import 'package:path_provider/path_provider.dart';
1111class StoreImpl with Store {
1212 final bool storageJson;
1313 late final Future <void > _migrationCompleted;
14-
14+
15+ // Queue size limits (bytes)
16+ static const int _kMaxQueueBytes = 512 * 1024 ; // 512 KB
17+ static const int _kTargetQueueBytes = 475 * 1024 ; // target after trimming 475 KB
18+
19+ // The fileKey used by the queue/flushing plugin in this repo.
20+ // The file produced will be analytics-flutter-queue_flushing_plugin.json
21+ static const String _kQueueFileKey = 'queue_flushing_plugin' ;
22+
23+ // The field name inside the JSON payload that holds events (adjusted per user's example)
24+ // The user's queue JSON uses a top-level "queue" array.
25+ static const String _kQueueField = 'queue' ;
26+
1527 StoreImpl ({this .storageJson = true }) {
1628 // Start migration immediately but don't block construction
1729 _migrationCompleted = _migrateFilesFromDocumentsToSupport ();
1830 }
31+
1932 @override
2033 Future get ready => Future .value ();
2134
35+ @override
36+ void dispose () {}
37+
2238 @override
2339 Future <Map <String , dynamic >?> getPersisted (String key) async {
2440 if (! storageJson) return Future .value (null );
@@ -34,7 +50,7 @@ class StoreImpl with Store {
3450 await _migrationCompleted;
3551 return _writeFile (key, value);
3652 }
37-
53+
3854 @override
3955 Future deletePersisted (String key) async {
4056 if (! storageJson) return ;
@@ -45,37 +61,219 @@ class StoreImpl with Store {
4561 }
4662
4763 Future _writeFile (String fileKey, Map <String , dynamic > data) async {
48- RandomAccessFile file =
49- await _getFile (fileKey, create: true ) as RandomAccessFile ;
50- final serialized = json.encode (data);
51- final buffer = utf8.encode (serialized);
52-
53- file.lockSync (FileLock .blockingExclusive);
54- file.setPositionSync (0 );
55- file.writeFromSync (buffer);
56- file.truncateSync (buffer.length);
57- file.unlockSync ();
58- file.closeSync ();
64+ // Serialize once; may be replaced by trimmed version below
65+ String serialized = json.encode (data);
66+ List <int > buffer = utf8.encode (serialized);
67+
68+ // If this is the queue file, enforce size limits by trimming oldest events
69+ if (fileKey == _kQueueFileKey) {
70+ // Ensure we operate against the configured queue field name.
71+ final currentEvents = < dynamic > [];
72+ if (data[_kQueueField] is List ) {
73+ currentEvents.addAll (data[_kQueueField] as List );
74+ }
75+
76+ // If the serialized payload is already too large, attempt trim before writing
77+ if (buffer.length > _kMaxQueueBytes) {
78+ final trimmed = _trimQueueToTargetMap (data, _kTargetQueueBytes, _kQueueField);
79+ serialized = json.encode (trimmed);
80+ buffer = utf8.encode (serialized);
81+
82+ // If still too big after trimming to target, write empty queue as last resort
83+ if (buffer.length > _kMaxQueueBytes) {
84+ serialized = json.encode ({_kQueueField: []});
85+ buffer = utf8.encode (serialized);
86+ }
87+ }
88+ }
89+
90+ RandomAccessFile ? file;
91+ try {
92+ file = await _getFile (fileKey, create: true ) as RandomAccessFile ;
93+ // Acquire exclusive lock and write
94+ file.lockSync (FileLock .blockingExclusive);
95+ try {
96+ file.setPositionSync (0 );
97+ file.writeFromSync (buffer);
98+ file.truncateSync (buffer.length);
99+ } finally {
100+ // Ensure unlock and close
101+ try {
102+ file.unlockSync ();
103+ } catch (_) {}
104+ try {
105+ file.closeSync ();
106+ } catch (_) {}
107+ }
108+ return ;
109+ } on FileSystemException catch (_) {
110+ // Recovery path for disk full / write errors for queue file
111+ if (fileKey != _kQueueFileKey) {
112+ rethrow ;
113+ }
114+
115+ // Try reading existing stored events and aggressively trim until we can write
116+ try {
117+ final existing = await _readFile (fileKey);
118+ List <dynamic > events =
119+ (existing != null && existing[_kQueueField] is List )
120+ ? List <dynamic >.from (existing[_kQueueField] as List )
121+ : < dynamic > [];
122+
123+ // Try progressively trimming and writing, removing oldest events first
124+ while (true ) {
125+ // Build candidate payload
126+ final candidateMap = {_kQueueField: events};
127+ final candidateText = json.encode (candidateMap);
128+ final candidateBuffer = utf8.encode (candidateText);
129+
130+ if (candidateBuffer.length <= _kMaxQueueBytes) {
131+ // Attempt to write this candidate
132+ RandomAccessFile ? f;
133+ try {
134+ f = await _getFile (fileKey, create: true ) as RandomAccessFile ;
135+ f.lockSync (FileLock .blockingExclusive);
136+ try {
137+ f.setPositionSync (0 );
138+ f.writeFromSync (candidateBuffer);
139+ f.truncateSync (candidateBuffer.length);
140+ // success
141+ try {
142+ f.unlockSync ();
143+ } catch (_) {}
144+ try {
145+ f.closeSync ();
146+ } catch (_) {}
147+ return ;
148+ } finally {
149+ // Ensure unlock/close in case of failures inside try
150+ try {
151+ f.unlockSync ();
152+ } catch (_) {}
153+ try {
154+ f.closeSync ();
155+ } catch (_) {}
156+ }
157+ } on FileSystemException {
158+ // Couldn't write; fall through to trimming more events
159+ }
160+ }
161+
162+ // If no events left, try to write an empty queue
163+ if (events.isEmpty) {
164+ final emptyBuf = utf8.encode (json.encode ({_kQueueField: []}));
165+ try {
166+ RandomAccessFile ? f2 =
167+ await _getFile (fileKey, create: true ) as RandomAccessFile ;
168+ f2.lockSync (FileLock .blockingExclusive);
169+ try {
170+ f2.setPositionSync (0 );
171+ f2.writeFromSync (emptyBuf);
172+ f2.truncateSync (emptyBuf.length);
173+ try {
174+ f2.unlockSync ();
175+ } catch (_) {}
176+ try {
177+ f2.closeSync ();
178+ } catch (_) {}
179+ return ;
180+ } finally {
181+ try {
182+ f2.unlockSync ();
183+ } catch (_) {}
184+ try {
185+ f2.closeSync ();
186+ } catch (_) {}
187+ }
188+ } on FileSystemException {
189+ // rethrow original error
190+ rethrow ;
191+ }
192+ }
193+
194+ // Remove the oldest event and try again
195+ events.removeAt (0 );
196+ }
197+ } catch (e) {
198+ // If recovery fails, rethrow the original exception
199+ rethrow ;
200+ }
201+ } finally {
202+ // Ensure any opened file is closed/unlocked (defensive)
203+ try {
204+ // Use null-aware calls to avoid calling on null.
205+ file? .unlockSync ();
206+ } catch (_) {}
207+ try {
208+ file? .closeSync ();
209+ } catch (_) {}
210+ }
211+ }
212+
213+ /// Trim the queue in [data] (expected to be a map containing an array under [queueField] )
214+ /// until the serialized size is <= targetBytes. Returns a new Map containing
215+ /// the trimmed queue list.
216+ Map <String , dynamic > _trimQueueToTargetMap (
217+ Map <String , dynamic > data, int targetBytes, String queueField) {
218+ final events = < dynamic > [];
219+ if (data[queueField] is List ) {
220+ events.addAll (data[queueField] as List );
221+ }
222+
223+ // If no events or not a list, return minimal representation
224+ if (events.isEmpty) {
225+ return {queueField: []};
226+ }
227+
228+ // Fast path: if current serialized size is already small enough, return input
229+ var candidate = {queueField: events};
230+ var s = json.encode (candidate);
231+ var b = utf8.encode (s);
232+ if (b.length <= targetBytes) {
233+ return candidate;
234+ }
235+
236+ // Iteratively remove oldest events until serialized size <= targetBytes or no events left
237+ while (b.length > targetBytes && events.isNotEmpty) {
238+ events.removeAt (0 );
239+ candidate = {queueField: events};
240+ s = json.encode (candidate);
241+ b = utf8.encode (s);
242+ }
243+
244+ return {queueField: events};
59245 }
60246
61247 Future <Map <String , dynamic >?> _readFile (String fileKey) async {
62248 RandomAccessFile ? file = await _getFile (fileKey);
63249 if (file == null ) {
64250 return null ;
65251 }
66- file = await file.lock (FileLock .blockingShared);
67- final length = file.lengthSync ();
68- file.setPositionSync (0 );
69- final buffer = Uint8List (length);
70- file.readIntoSync (buffer);
71- file.unlockSync ();
72- file.closeSync ();
73- final contentText = utf8.decode (buffer);
74- if (contentText == "{}" ) {
75- return null ; // Prefer null to empty map, because we'll want to initialise a valid empty value.
76- }
77252
78- return json.decode (contentText) as Map <String , dynamic >;
253+ try {
254+ file = await file.lock (FileLock .blockingShared);
255+ final length = file.lengthSync ();
256+ file.setPositionSync (0 );
257+ final buffer = Uint8List (length);
258+ file.readIntoSync (buffer);
259+ file.unlockSync ();
260+ file.closeSync ();
261+ final contentText = utf8.decode (buffer);
262+ if (contentText == "{}" ) {
263+ return null ; // empty file
264+ }
265+
266+ return json.decode (contentText) as Map <String , dynamic >;
267+ } on FileSystemException {
268+ // Can't read the file -> return null for safety
269+ try {
270+ file? .unlockSync ();
271+ } catch (_) {}
272+ try {
273+ file? .closeSync ();
274+ } catch (_) {}
275+ return null ;
276+ }
79277 }
80278
81279 Future <String > _fileName (String fileKey) async {
@@ -88,6 +286,7 @@ class StoreImpl with Store {
88286 final file = File (await _fileName (fileKey));
89287
90288 if (await file.exists ()) {
289+ // Open in append mode so we can lock and then write/truncate
91290 return await file.open (mode: FileMode .append);
92291 } else if (create) {
93292 await file.create (recursive: true );
@@ -113,44 +312,62 @@ class StoreImpl with Store {
113312 }
114313 }
115314
116- /// Migrates existing analytics files from Documents directory to Application Support directory
315+ /// Move any analytics-flutter-*.json files from Documents (old location) to
316+ /// ApplicationSupport (new location). This migration is best-effort and will
317+ /// ignore errors so SDK initialization can proceed.
117318 Future <void > _migrateFilesFromDocumentsToSupport () async {
319+ if (! storageJson) return ;
118320 try {
119321 final oldDir = await _getOldDocumentDir ();
120322 final newDir = await _getNewDocumentDir ();
121-
122- // List all analytics files in the old directory
123- final oldDirFiles = oldDir.listSync ()
124- .whereType <File >()
125- .where ((file) => file.path.contains ('analytics-flutter-' ) && file.path.endsWith ('.json' ))
126- .toList ();
127-
128- for (final oldFile in oldDirFiles) {
129- final fileName = oldFile.path.split ('/' ).last;
130- final newFilePath = '${newDir .path }/$fileName ' ;
131- final newFile = File (newFilePath);
132-
133- // Only migrate if the file doesn't already exist in the new location
134- if (! await newFile.exists ()) {
135- try {
136- // Ensure the new directory exists
137- await newDir.create (recursive: true );
138-
139- // Copy the file to the new location
140- await oldFile.copy (newFilePath);
141-
142- // Delete the old file after successful copy
143- await oldFile.delete ();
144- } catch (e) {
145- // The app should continue to work even if migration fails
323+
324+ // If same path, nothing to do
325+ if (oldDir.path == newDir.path) return ;
326+
327+ // Ensure new dir exists
328+ try {
329+ if (! await Directory (newDir.path).exists ()) {
330+ await Directory (newDir.path).create (recursive: true );
331+ }
332+ } catch (_) {}
333+
334+ // List files in old directory and move ones that match analytics-flutter-*.json
335+ final oldDirectory = Directory (oldDir.path);
336+ if (! await oldDirectory.exists ()) return ;
337+
338+ await for (final entity in oldDirectory.list ()) {
339+ if (entity is File ) {
340+ final name = entity.uri.pathSegments.isNotEmpty
341+ ? entity.uri.pathSegments.last
342+ : '' ;
343+ if (name.startsWith ('analytics-flutter-' ) &&
344+ name.endsWith ('.json' )) {
345+ final destPath = '${newDir .path }/$name ' ;
346+ final destFile = File (destPath);
347+
348+ try {
349+ // If destination already exists, skip or optionally merge - we skip here.
350+ if (! await destFile.exists ()) {
351+ await entity.rename (destPath);
352+ } else {
353+ // If a file already exists at the destination, attempt to remove the old file.
354+ try {
355+ await entity.delete ();
356+ } catch (_) {}
357+ }
358+ } catch (_) {
359+ // Try fallback: copy then delete
360+ try {
361+ await entity.copy (destPath);
362+ await entity.delete ();
363+ } catch (_) {
364+ }
365+ }
146366 }
147367 }
148368 }
149- } catch (e ) {
150- // Migration failure shouldn't break the app
369+ } catch (_ ) {
370+ return ;
151371 }
152372 }
153-
154- @override
155- void dispose () {}
156- }
373+ }
0 commit comments