diff --git a/demos/supabase-todolist/lib/attachments/local_storage_web.dart b/demos/supabase-todolist/lib/attachments/local_storage_web.dart new file mode 100644 index 00000000..05c46381 --- /dev/null +++ b/demos/supabase-todolist/lib/attachments/local_storage_web.dart @@ -0,0 +1,6 @@ +import 'package:powersync_core/attachments/attachments.dart'; +import 'package:powersync_core/attachments/web.dart'; + +Future localAttachmentStorage() async { + return OpfsLocalStorage('powersync_attachments'); +} diff --git a/demos/supabase-todolist/lib/attachments/photo_widget.dart b/demos/supabase-todolist/lib/attachments/photo_widget.dart index f41bc0b0..4fcfc35c 100644 --- a/demos/supabase-todolist/lib/attachments/photo_widget.dart +++ b/demos/supabase-todolist/lib/attachments/photo_widget.dart @@ -1,16 +1,18 @@ import 'dart:io'; +import 'dart:typed_data'; -import 'package:path_provider/path_provider.dart'; -import 'package:path/path.dart' as p; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:powersync_core/attachments/attachments.dart'; +import 'package:powersync_core/attachments/io.dart'; import 'package:powersync_flutter_demo/attachments/camera_helpers.dart'; import 'package:powersync_flutter_demo/attachments/photo_capture_widget.dart'; import '../models/todo_item.dart'; import '../powersync.dart'; +import 'queue.dart'; -class PhotoWidget extends StatefulWidget { +class PhotoWidget extends StatelessWidget { final TodoItem todo; PhotoWidget({ @@ -18,119 +20,171 @@ class PhotoWidget extends StatefulWidget { }) : super(key: ObjectKey(todo.id)); @override - State createState() { - return _PhotoWidgetState(); + Widget build(BuildContext context) { + return StreamBuilder( + stream: _attachmentState(todo.photoId), + builder: (context, snapshot) { + if (snapshot.data == null) { + return Container(); + } + final data = snapshot.data!; + final attachment = data.attachment; + if (todo.photoId == null || attachment == null) { + return TakePhotoButton(todoId: todo.id); + } + + var fileArchived = data.attachment?.state == AttachmentState.archived; + + if (fileArchived) { + return Column( + crossAxisAlignment: CrossAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const Text("Unavailable"), + const SizedBox(height: 8), + TakePhotoButton(todoId: todo.id), + ], + ); + } + + if (!data.fileExists) { + return const Text('Downloading...'); + } + + if (kIsWeb) { + // We can't use Image.file on the web, so fall back to loading the + // image from OPFS. + return _WebAttachmentImage(attachment: attachment); + } else { + final path = + (localStorage as IOLocalStorage).pathFor(attachment.filename); + return Image.file( + key: ValueKey(attachment), + File(path), + width: 50, + height: 50, + ); + } + }, + ); + } + + static Stream<_AttachmentState> _attachmentState(String? id) { + return db.watch('SELECT * FROM attachments_queue WHERE id = ?', + parameters: [id]).asyncMap((rows) async { + if (rows.isEmpty) { + return const _AttachmentState(null, false); + } + + final attachment = Attachment.fromRow(rows.single); + final exists = await localStorage.fileExists(attachment.filename); + return _AttachmentState(attachment, exists); + }); } } -class _ResolvedPhotoState { - String? photoPath; - bool fileExists; - Attachment? attachment; +class TakePhotoButton extends StatelessWidget { + final String todoId; - _ResolvedPhotoState( - {required this.photoPath, required this.fileExists, this.attachment}); + const TakePhotoButton({super.key, required this.todoId}); + + @override + Widget build(BuildContext context) { + return ElevatedButton( + onPressed: () async { + final camera = await setupCamera(); + if (!context.mounted) return; + + if (camera == null) { + const snackBar = SnackBar( + content: Text('No camera available'), + backgroundColor: Colors.red, // Optional: to highlight it's an error + ); + + ScaffoldMessenger.of(context).showSnackBar(snackBar); + return; + } + + Navigator.push( + context, + MaterialPageRoute( + builder: (context) => + TakePhotoWidget(todoId: todoId, camera: camera), + ), + ); + }, + child: const Text('Take Photo'), + ); + } } -class _PhotoWidgetState extends State { - late String photoPath; +final class _AttachmentState { + final Attachment? attachment; + final bool fileExists; - Future<_ResolvedPhotoState> _getPhotoState(photoId) async { - if (photoId == null) { - return _ResolvedPhotoState(photoPath: null, fileExists: false); - } - final appDocDir = await getApplicationDocumentsDirectory(); - photoPath = p.join(appDocDir.path, '$photoId.jpg'); + const _AttachmentState(this.attachment, this.fileExists); +} - bool fileExists = await File(photoPath).exists(); +/// A widget showing an [Attachment] as an image by loading it into memory. +/// +/// On native platforms, using a file path is more efficient. +class _WebAttachmentImage extends StatefulWidget { + final Attachment attachment; - final row = await db - .getOptional('SELECT * FROM attachments_queue WHERE id = ?', [photoId]); + const _WebAttachmentImage({required this.attachment}); - if (row != null) { - Attachment attachment = Attachment.fromRow(row); - return _ResolvedPhotoState( - photoPath: photoPath, fileExists: fileExists, attachment: attachment); - } + @override + State<_WebAttachmentImage> createState() => _AttachmentImageState(); +} - return _ResolvedPhotoState( - photoPath: photoPath, fileExists: fileExists, attachment: null); +class _AttachmentImageState extends State<_WebAttachmentImage> { + Future? _imageBytes; + + void _loadBytes() { + setState(() { + _imageBytes = Future(() async { + final buffer = BytesBuilder(); + if (!await localStorage.fileExists(widget.attachment.filename)) { + return null; + } + + await localStorage + .readFile(widget.attachment.filename) + .forEach(buffer.add); + return buffer.takeBytes(); + }); + }); } @override - Widget build(BuildContext context) { - return FutureBuilder( - future: _getPhotoState(widget.todo.photoId), - builder: (BuildContext context, - AsyncSnapshot<_ResolvedPhotoState> snapshot) { - if (snapshot.data == null) { - return Container(); - } - final data = snapshot.data!; - Widget takePhotoButton = ElevatedButton( - onPressed: () async { - final camera = await setupCamera(); - if (!context.mounted) return; - - if (camera == null) { - const snackBar = SnackBar( - content: Text('No camera available'), - backgroundColor: - Colors.red, // Optional: to highlight it's an error - ); - - ScaffoldMessenger.of(context).showSnackBar(snackBar); - return; - } - - Navigator.push( - context, - MaterialPageRoute( - builder: (context) => - TakePhotoWidget(todoId: widget.todo.id, camera: camera), - ), - ); - }, - child: const Text('Take Photo'), - ); + void initState() { + super.initState(); + _loadBytes(); + } - if (widget.todo.photoId == null) { - return takePhotoButton; - } - - String? filePath = data.photoPath; - bool fileIsDownloading = !data.fileExists; - bool fileArchived = - data.attachment?.state == AttachmentState.archived; - - if (fileArchived) { - return Column( - crossAxisAlignment: CrossAxisAlignment.center, - mainAxisAlignment: MainAxisAlignment.center, - children: [ - const Text("Unavailable"), - const SizedBox(height: 8), - takePhotoButton - ], - ); - } - - if (fileIsDownloading) { - return const Text("Downloading..."); - } - - File imageFile = File(filePath!); - int lastModified = imageFile.existsSync() - ? imageFile.lastModifiedSync().millisecondsSinceEpoch - : 0; - Key key = ObjectKey('$filePath:$lastModified'); + @override + void didUpdateWidget(covariant _WebAttachmentImage oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.attachment != widget.attachment) { + _loadBytes(); + } + } - return Image.file( - key: key, - imageFile, + @override + Widget build(BuildContext context) { + return FutureBuilder( + future: _imageBytes, + builder: (context, snapshot) { + if (snapshot.data case final bytes?) { + return Image.memory( + bytes, width: 50, height: 50, ); - }); + } else { + return Container(); + } + }, + ); } } diff --git a/demos/supabase-todolist/lib/attachments/queue.dart b/demos/supabase-todolist/lib/attachments/queue.dart index 80460daf..255640b2 100644 --- a/demos/supabase-todolist/lib/attachments/queue.dart +++ b/demos/supabase-todolist/lib/attachments/queue.dart @@ -7,9 +7,11 @@ import 'package:powersync_core/attachments/attachments.dart'; import 'package:powersync_flutter_demo/attachments/remote_storage_adapter.dart'; import 'local_storage_unsupported.dart' + if (dart.library.js_interop) 'local_storage_web.dart' if (dart.library.io) 'local_storage_native.dart'; late AttachmentQueue attachmentQueue; +late LocalStorage localStorage; final remoteStorage = SupabaseStorageAdapter(); final logger = Logger('AttachmentQueue'); @@ -18,7 +20,7 @@ Future initializeAttachmentQueue(PowerSyncDatabase db) async { db: db, remoteStorage: remoteStorage, logger: logger, - localStorage: await localAttachmentStorage(), + localStorage: localStorage = await localAttachmentStorage(), watchAttachments: () => db.watch(''' SELECT photo_id as id FROM todos WHERE photo_id IS NOT NULL ''').map( diff --git a/packages/powersync_core/lib/attachments/attachments.dart b/packages/powersync_core/lib/attachments/attachments.dart index a69f9409..288a0973 100644 --- a/packages/powersync_core/lib/attachments/attachments.dart +++ b/packages/powersync_core/lib/attachments/attachments.dart @@ -7,6 +7,6 @@ library; export '../src/attachments/attachment.dart'; export '../src/attachments/attachment_queue_service.dart'; -export '../src/attachments/local_storage.dart'; +export '../src/attachments/storage/local_storage.dart'; export '../src/attachments/remote_storage.dart'; export '../src/attachments/sync_error_handler.dart'; diff --git a/packages/powersync_core/lib/attachments/io.dart b/packages/powersync_core/lib/attachments/io.dart index 142abb26..8d04859c 100644 --- a/packages/powersync_core/lib/attachments/io.dart +++ b/packages/powersync_core/lib/attachments/io.dart @@ -6,7 +6,7 @@ /// {@category attachments} library; -import '../src/attachments/io_local_storage.dart'; -import '../src/attachments/local_storage.dart'; +import '../src/attachments/storage/io_local_storage.dart'; +import '../src/attachments/storage/local_storage.dart'; -export '../src/attachments/io_local_storage.dart'; +export '../src/attachments/storage/io_local_storage.dart'; diff --git a/packages/powersync_core/lib/attachments/web.dart b/packages/powersync_core/lib/attachments/web.dart new file mode 100644 index 00000000..9e966afd --- /dev/null +++ b/packages/powersync_core/lib/attachments/web.dart @@ -0,0 +1,12 @@ +/// A platform-specific import supporting attachments on the web. +/// +/// This library exports the [OpfsLocalStorage] class, implementing the +/// [LocalStorage] interface by storing files under a root directory. +/// +/// {@category attachments} +library; + +import '../src/attachments/storage/web_opfs_storage.dart'; +import '../src/attachments/storage/local_storage.dart'; + +export '../src/attachments/storage/web_opfs_storage.dart'; diff --git a/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart b/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart index 0ec673e0..bb75857a 100644 --- a/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart +++ b/packages/powersync_core/lib/src/attachments/attachment_queue_service.dart @@ -13,7 +13,7 @@ import 'package:sqlite_async/sqlite_async.dart'; import 'attachment.dart'; import 'implementations/attachment_context.dart'; -import 'local_storage.dart'; +import 'storage/local_storage.dart'; import 'remote_storage.dart'; import 'sync_error_handler.dart'; import 'implementations/attachment_service.dart'; diff --git a/packages/powersync_core/lib/src/attachments/io_local_storage.dart b/packages/powersync_core/lib/src/attachments/storage/io_local_storage.dart similarity index 91% rename from packages/powersync_core/lib/src/attachments/io_local_storage.dart rename to packages/powersync_core/lib/src/attachments/storage/io_local_storage.dart index 67d1b578..9aca006f 100644 --- a/packages/powersync_core/lib/src/attachments/io_local_storage.dart +++ b/packages/powersync_core/lib/src/attachments/storage/io_local_storage.dart @@ -22,7 +22,11 @@ final class IOLocalStorage implements LocalStorage { const IOLocalStorage(this._root); - File _fileFor(String filePath) => File(p.join(_root.path, filePath)); + /// Returns the path of a relative [filePath] resolved against this local + /// storage implementation. + String pathFor(String filePath) => p.join(_root.path, filePath); + + File _fileFor(String filePath) => File(pathFor(filePath)); @override Future saveFile(String filePath, Stream> data) async { diff --git a/packages/powersync_core/lib/src/attachments/local_storage.dart b/packages/powersync_core/lib/src/attachments/storage/local_storage.dart similarity index 95% rename from packages/powersync_core/lib/src/attachments/local_storage.dart rename to packages/powersync_core/lib/src/attachments/storage/local_storage.dart index c43db1ef..8ea09881 100644 --- a/packages/powersync_core/lib/src/attachments/local_storage.dart +++ b/packages/powersync_core/lib/src/attachments/storage/local_storage.dart @@ -32,7 +32,7 @@ abstract interface class LocalStorage { /// [filePath] - Path of the file to read /// /// Returns a stream of binary data - Stream readFile(String filePath); + Stream readFile(String filePath, {String? mediaType}); /// Deletes a file at the specified path /// @@ -79,7 +79,7 @@ final class _InMemoryStorage implements LocalStorage { Future initialize() async {} @override - Stream readFile(String filePath) { + Stream readFile(String filePath, {String? mediaType}) { return switch (content[_keyForPath(filePath)]) { null => Stream.error('file at $filePath does not exist in in-memory storage'), diff --git a/packages/powersync_core/lib/src/attachments/storage/web_opfs_storage.dart b/packages/powersync_core/lib/src/attachments/storage/web_opfs_storage.dart new file mode 100644 index 00000000..2c93ff6c --- /dev/null +++ b/packages/powersync_core/lib/src/attachments/storage/web_opfs_storage.dart @@ -0,0 +1,191 @@ +import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:typed_data'; + +import 'package:web/web.dart' as web; +import 'package:path/path.dart' as p; +import 'local_storage.dart'; + +/// A [LocalStorage] implementation suitable for the web, storing files in the +/// [Origin private file system](https://developer.mozilla.org/en-US/docs/Web/API/File_System_API/Origin_private_file_system). +final class OpfsLocalStorage implements LocalStorage { + final Future Function() _root; + Future? _resolvedDirectory; + + OpfsLocalStorage._(this._root); + + /// Creates a [LocalStorage] implementation storing files in OPFS. + /// + /// The [rootDirectory] acts as a chroot within `navigator.getDirectory()`, + /// and allows storing attachments in a subdirectory. + /// Users are strongly encouraged to set it, as [clear] would otherwise delete + /// all of OFPS. + factory OpfsLocalStorage(String rootDirectory) { + return OpfsLocalStorage._(() async { + var root = await _navigator.storage.getDirectory().toDart; + for (final segment in p.url.split(rootDirectory)) { + root = await root.getDirectory(segment, create: true); + } + + return root; + }); + } + + Future get root { + return _resolvedDirectory ??= _root(); + } + + Future<(web.FileSystemDirectoryHandle, String)> _parentDirectoryAndName( + String path, + {bool create = false}) async { + final segments = p.url.split(path); + var dir = await root; + for (var i = 0; i < segments.length - 1; i++) { + dir = await dir.getDirectory(segments[i], create: create); + } + + return (dir, segments.last); + } + + Future _file(String path, + {bool create = false}) async { + final (parent, name) = await _parentDirectoryAndName(path, create: create); + return await parent + .getFileHandle(name, web.FileSystemGetFileOptions(create: create)) + .toDart; + } + + @override + Future clear() async { + final dir = await root; + await for (final entry in dir.values().toDart) { + await dir.remove(entry.name, recursive: true); + } + } + + @override + Future deleteFile(String filePath) async { + try { + final (parent, name) = await _parentDirectoryAndName(filePath); + await parent.remove(name); + } catch (e) { + // Entry does not exist, skip. + return; + } + } + + @override + Future fileExists(String filePath) async { + try { + await _file(filePath); + return true; + } catch (e) { + // Entry does not exist, skip. + return false; + } + } + + @override + Future initialize() async { + await root; + } + + @override + Stream readFile(String filePath, {String? mediaType}) async* { + final file = await _file(filePath); + final completer = Completer.sync(); + final reader = web.FileReader(); + reader + ..onload = () { + final data = (reader.result as JSArrayBuffer).toDart; + completer.complete(data.asUint8List()); + }.toJS + ..onerror = () { + completer.completeError(reader.error!); + }.toJS; + + reader.readAsArrayBuffer(await file.getFile().toDart); + yield await completer.future; + } + + @override + Future saveFile(String filePath, Stream> data) async { + final file = await _file(filePath, create: true); + final writable = await file.createWritable().toDart; + + var bytesWritten = 0; + await for (final chunk in data) { + final asBuffer = switch (chunk) { + final Uint8List blob => blob, + _ => Uint8List.fromList(chunk), + }; + + await writable.write(asBuffer.toJS).toDart; + bytesWritten += asBuffer.length; + } + + await writable.close().toDart; + return bytesWritten; + } +} + +@JS('Symbol.asyncIterator') +external JSSymbol get _asyncIterator; + +@JS('navigator') +external web.Navigator get _navigator; + +extension FileSystemHandleApi on web.FileSystemHandle { + bool get isFile => kind == 'file'; + + bool get isDirectory => kind == 'directory'; +} + +extension FileSystemDirectoryHandleApi on web.FileSystemDirectoryHandle { + Future openFile(String name, + {bool create = false}) { + return getFileHandle(name, web.FileSystemGetFileOptions(create: create)) + .toDart; + } + + Future getDirectory(String name, + {bool create = false}) { + return getDirectoryHandle( + name, web.FileSystemGetDirectoryOptions(create: create)) + .toDart; + } + + Future remove(String name, {bool recursive = false}) { + return removeEntry(name, web.FileSystemRemoveOptions(recursive: recursive)) + .toDart; + } + + external AsyncIterable values(); +} + +extension type IteratorResult(JSObject _) + implements JSObject { + external JSBoolean? get done; + external T? get value; +} + +extension type AsyncIterator(JSObject _) implements JSObject { + external JSPromise> next(); +} + +extension type AsyncIterable(JSObject _) implements JSObject { + Stream get toDart async* { + final iterator = (getProperty(_asyncIterator) as JSFunction) + .callAsFunction(this) as AsyncIterator; + + while (true) { + final next = await iterator.next().toDart; + if (next.done?.toDart == true) { + break; + } + + yield next.value as T; + } + } +} diff --git a/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart b/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart index 4bc1a266..416afe5a 100644 --- a/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart +++ b/packages/powersync_core/lib/src/attachments/sync/syncing_service.dart @@ -7,7 +7,7 @@ import 'package:async/async.dart'; import '../attachment.dart'; import '../implementations/attachment_context.dart'; import '../implementations/attachment_service.dart'; -import '../local_storage.dart'; +import '../storage/local_storage.dart'; import '../remote_storage.dart'; import '../sync_error_handler.dart'; diff --git a/packages/powersync_core/test/attachments/local_storage_test.dart b/packages/powersync_core/test/attachments/local_storage_test.dart index 9ceabac1..9fdab320 100644 --- a/packages/powersync_core/test/attachments/local_storage_test.dart +++ b/packages/powersync_core/test/attachments/local_storage_test.dart @@ -7,7 +7,7 @@ import 'dart:typed_data'; import 'package:test/test.dart'; import 'package:path/path.dart' as p; -import 'package:powersync_core/src/attachments/io_local_storage.dart'; +import 'package:powersync_core/src/attachments/storage/io_local_storage.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; void main() { @@ -29,6 +29,7 @@ void main() { final data = Uint8List.fromList([1, 2, 3, 4, 5]); final size = await storage.saveFile(filePath, Stream.value(data)); expect(size, equals(data.length)); + expect(storage.pathFor(filePath), d.path('test_file')); final resultStream = storage.readFile(filePath); final result = await resultStream.toList(); diff --git a/packages/powersync_core/test/attachments/opfs_storage_test.dart b/packages/powersync_core/test/attachments/opfs_storage_test.dart new file mode 100644 index 00000000..94d3a33c --- /dev/null +++ b/packages/powersync_core/test/attachments/opfs_storage_test.dart @@ -0,0 +1,276 @@ +@TestOn('browser') +library; + +import 'dart:typed_data'; + +import 'package:collection/collection.dart'; +import 'package:powersync_core/src/attachments/storage/web_opfs_storage.dart'; +import 'package:test/test.dart'; + +void main() { + group('OpfsLocalStorage', () { + late OpfsLocalStorage storage; + + setUp(() async { + storage = OpfsLocalStorage( + 'dart-test-${DateTime.now().millisecondsSinceEpoch}'); + await storage.initialize(); + }); + + tearDown(() async { + await storage.clear(); + }); + + group('saveFile and readFile', () { + test('saves and reads binary data successfully', () async { + const filePath = 'test_file'; + final data = Uint8List.fromList([1, 2, 3, 4, 5]); + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + }); + + test('throws when reading non-existent file', () async { + const filePath = 'non_existent'; + expect( + () => storage.readFile(filePath).toList(), + throwsA(anything), + ); + }); + + test('creates parent directories if they do not exist', () async { + const filePath = 'subdir/nested/test'; + final data = Uint8List.fromList([1, 2, 3]); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + }); + + test('creates all parent directories for deeply nested file', () async { + const filePath = 'a/b/c/d/e/f/g/h/i/j/testfile'; + final data = Uint8List.fromList([42, 43, 44]); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + }); + + test('overwrites existing file', () async { + const filePath = 'overwrite_test'; + final originalData = Uint8List.fromList([1, 2, 3]); + final newData = Uint8List.fromList([4, 5, 6, 7]); + + await storage.saveFile(filePath, Stream.value(originalData)); + final size = await storage.saveFile(filePath, Stream.value(newData)); + expect(size, equals(newData.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([newData])); + }); + }); + + group('edge cases and robustness', () { + test('saveFile with empty data writes empty file and returns 0 size', + () async { + const filePath = 'empty_file'; + + final size = await storage.saveFile(filePath, Stream.empty()); + expect(size, 0); + + final resultStream = storage.readFile(filePath); + final chunks = await resultStream.toList(); + expect(chunks.flattenedToList, isEmpty); + }); + + test('readFile preserves byte order (chunking may differ)', () async { + const filePath = 'ordered_chunks'; + final chunks = [ + Uint8List.fromList([0, 1, 2]), + Uint8List.fromList([3, 4]), + Uint8List.fromList([5, 6, 7, 8]), + ]; + final expectedBytes = + Uint8List.fromList(chunks.expand((c) => c).toList()); + await storage.saveFile(filePath, Stream.value(expectedBytes)); + + final outChunks = await storage.readFile(filePath).toList(); + final outBytes = Uint8List.fromList( + outChunks.expand((c) => c).toList(), + ); + expect(outBytes, equals(expectedBytes)); + }); + + test('fileExists becomes false after deleteFile', () async { + const filePath = 'exists_then_delete'; + await storage.saveFile(filePath, Stream.value(Uint8List.fromList([1]))); + expect(await storage.fileExists(filePath), isTrue); + await storage.deleteFile(filePath); + expect(await storage.fileExists(filePath), isFalse); + }); + + test('initialize is idempotent', () async { + await storage.initialize(); + await storage.initialize(); + + // Create a file, then re-initialize again + const filePath = 'idempotent_test'; + await storage.saveFile(filePath, Stream.value(Uint8List.fromList([9]))); + await storage.initialize(); + + // File should still exist (initialize should not clear data) + expect(await storage.fileExists(filePath), isTrue); + }); + + test('supports unicode and emoji filenames', () async { + const filePath = '測試_файл_📷.bin'; + final bytes = Uint8List.fromList([10, 20, 30, 40]); + await storage.saveFile(filePath, Stream.value(bytes)); + + final out = await storage.readFile(filePath).toList(); + expect(out, equals([bytes])); + }); + + test('readFile accepts mediaType parameter (ignored by IO impl)', + () async { + const filePath = 'with_media_type'; + final data = Uint8List.fromList([1, 2, 3]); + await storage.saveFile(filePath, Stream.value(data)); + + final result = + await storage.readFile(filePath, mediaType: 'image/jpeg').toList(); + expect(result, equals([data])); + }); + }); + + group('deleteFile', () { + test('deletes existing file', () async { + const filePath = 'delete_test'; + final data = Uint8List.fromList([1, 2, 3]); + + await storage.saveFile(filePath, Stream.value(data)); + expect(await storage.fileExists(filePath), isTrue); + + await storage.deleteFile(filePath); + expect(await storage.fileExists(filePath), isFalse); + }); + + test('does not throw when deleting non-existent file', () async { + const filePath = 'non_existent'; + await storage.deleteFile(filePath); + }); + }); + + test('clear', () async { + await storage.saveFile('foo', Stream.value([])); + expect(await storage.fileExists('foo'), isTrue); + await storage.clear(); + expect(await storage.fileExists('foo'), isFalse); + }); + + group('fileExists', () { + test('returns true for existing file', () async { + const filePath = 'exists_test'; + final data = Uint8List.fromList([1, 2, 3]); + + await storage.saveFile(filePath, Stream.value(data)); + expect(await storage.fileExists(filePath), isTrue); + }); + + test('returns false for non-existent file', () async { + const filePath = 'non_existent'; + expect(await storage.fileExists(filePath), isFalse); + }); + }); + + group('file system integration', () { + test('handles special characters in file path', () async { + const filePath = 'file with spaces & symbols!@#'; + final data = Uint8List.fromList([1, 2, 3]); + + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, equals([data])); + }); + + test('handles large binary data stream', () async { + const filePath = 'large_file'; + final data = Uint8List.fromList(List.generate(10000, (i) => i % 256)); + final chunkSize = 1000; + final chunks = []; + for (var i = 0; i < data.length; i += chunkSize) { + chunks.add( + Uint8List.fromList( + data.sublist( + i, + i + chunkSize < data.length ? i + chunkSize : data.length, + ), + ), + ); + } + final size = await storage.saveFile(filePath, Stream.value(data)); + expect(size, equals(data.length)); + + final resultStream = storage.readFile(filePath); + final result = Uint8List.fromList( + (await resultStream.toList()).expand((chunk) => chunk).toList(), + ); + expect(result, equals(data)); + }); + }); + + group('concurrent operations', () { + test('handles concurrent saves to different files', () async { + final futures = >[]; + final fileCount = 10; + + for (int i = 0; i < fileCount; i++) { + final data = Uint8List.fromList([i, i + 1, i + 2]); + futures.add(storage.saveFile('file_$i', Stream.value(data))); + } + + await Future.wait(futures); + + for (int i = 0; i < fileCount; i++) { + final resultStream = storage.readFile('file_$i'); + final result = await resultStream.toList(); + expect( + result, + equals([ + Uint8List.fromList([i, i + 1, i + 2]), + ]), + ); + } + }); + + test('handles concurrent saves to the same file', () async { + const filePath = 'concurrent_test'; + final data1 = Uint8List.fromList([1, 2, 3]); + final data2 = Uint8List.fromList([4, 5, 6]); + final futures = [ + storage.saveFile(filePath, Stream.value(data1)), + storage.saveFile(filePath, Stream.value(data2)), + ]; + + await Future.wait(futures); + + final resultStream = storage.readFile(filePath); + final result = await resultStream.toList(); + expect(result, anyOf(equals([data1]), equals([data2]))); + }); + }); + }); +}