diff --git a/packages/file_picker_web/CHANGELOG.md b/packages/file_picker_web/CHANGELOG.md index b79de54f..8596955d 100644 --- a/packages/file_picker_web/CHANGELOG.md +++ b/packages/file_picker_web/CHANGELOG.md @@ -1,3 +1,8 @@ +## 4.1.0 + +- `readAsByteStream()` now always emits evenly sized chunks of `WebPlatformFile.streamChunkSize` (1 MiB), except the last one, with or without `withReadStream`. With `withReadStream` the chunks used to be 1,000,000 bytes. [#2223](https://github.com/vicajilau/flutter_file_picker/issues/2223) +- Deprecated `withData`, `withReadStream` and `readSequential` in `FilePickerWebOptions`. Picked files are read on demand by `readAsBytes()` and `readAsByteStream()`. They will be removed in the next major version. + ## 4.0.1 - Fixed picking files on Safari (macOS and iOS) never returning the selection. [#2222](https://github.com/vicajilau/flutter_file_picker/issues/2222) diff --git a/packages/file_picker_web/lib/src/file_picker_web.dart b/packages/file_picker_web/lib/src/file_picker_web.dart index 8dce7f50..d994cde7 100644 --- a/packages/file_picker_web/lib/src/file_picker_web.dart +++ b/packages/file_picker_web/lib/src/file_picker_web.dart @@ -9,6 +9,7 @@ import 'package:web/web.dart'; import 'file_picker_web_options.dart'; import 'indexed_task_runner.dart'; +import 'platform_file_web_fetch.dart'; import 'preload_policy.dart'; import 'web_file_input_session.dart'; import 'web_platform_file.dart'; @@ -20,7 +21,6 @@ import 'web_platform_file.dart'; /// in browser environments. class FilePickerWeb extends FilePickerPlatform { static const String _kFilePickerInputsDomId = '__file_picker_web-file-input'; - static const int _readStreamChunkSize = 1000 * 1000; // 1 MB late Element _target; @@ -158,7 +158,7 @@ class FilePickerWeb extends FilePickerPlatform { if (webOptions.withReadStream) { return _createWebPlatformFile( file: file, - readStream: _openFileReadStream(file), + readStream: streamBlobInChunks(file), ); } @@ -285,34 +285,4 @@ class FilePickerWeb extends FilePickerPlatform { ), }; } - - /// Opens a chunked byte stream reader for a web [File]. - Stream _openFileReadStream(File file) async* { - final reader = FileReader(); - - int start = 0; - while (start < file.size) { - final end = start + _readStreamChunkSize > file.size - ? file.size - : start + _readStreamChunkSize; - final blob = file.slice(start, end); - reader.readAsArrayBuffer(blob); - await EventStreamProviders.loadEvent.forTarget(reader).first; - final JSAny? readerResult = reader.result; - if (readerResult == null) { - continue; - } - - if (readerResult.isA()) { - yield (readerResult as JSArrayBuffer).toDart.asUint8List(); - start += _readStreamChunkSize; - continue; - } - - if (readerResult.isA()) { - yield Uint8List.fromList((readerResult as JSArray).toDart.cast()); - start += _readStreamChunkSize; - } - } - } } diff --git a/packages/file_picker_web/lib/src/file_picker_web_options.dart b/packages/file_picker_web/lib/src/file_picker_web_options.dart index b7a6f7a1..627d42ee 100644 --- a/packages/file_picker_web/lib/src/file_picker_web_options.dart +++ b/packages/file_picker_web/lib/src/file_picker_web_options.dart @@ -7,15 +7,27 @@ final class FilePickerWebOptions extends WebOptions { /// Files larger than 2 GB are never preloaded, since browsers cannot hold /// them in a single buffer. Their content can be read on demand through /// `PlatformFile.readAsBytes()` and `readAsByteStream()` instead. + @Deprecated( + 'Picked files are read on demand by PlatformFile.readAsBytes() and ' + 'readAsByteStream(). This option will be removed in file_picker v14.0.0.', + ) final bool withData; /// Whether to create a read stream for each picked file. + @Deprecated( + 'PlatformFile.readAsByteStream() already reads on demand in evenly sized ' + 'chunks. This option will be removed in file_picker v14.0.0.', + ) final bool withReadStream; /// Whether to read multiple files one at a time instead of concurrently. /// /// The result always preserves the original selection order, regardless /// of this setting. + @Deprecated( + 'Only affects the deprecated withData preload. This option will be ' + 'removed in file_picker v14.0.0.', + ) final bool readSequential; /// Whether to cancel upload when window loses focus. @@ -23,8 +35,20 @@ final class FilePickerWebOptions extends WebOptions { /// Creates an instance of [FilePickerWebOptions]. const FilePickerWebOptions({ + @Deprecated( + 'Picked files are read on demand by PlatformFile.readAsBytes() and ' + 'readAsByteStream(). This option will be removed in file_picker v14.0.0.', + ) this.withData = true, + @Deprecated( + 'PlatformFile.readAsByteStream() already reads on demand in evenly ' + 'sized chunks. This option will be removed in file_picker v14.0.0.', + ) this.withReadStream = false, + @Deprecated( + 'Only affects the deprecated withData preload. This option will be ' + 'removed in file_picker v14.0.0.', + ) this.readSequential = false, this.cancelUploadOnWindowBlur = true, }); diff --git a/packages/file_picker_web/lib/src/platform_file_web_fetch.dart b/packages/file_picker_web/lib/src/platform_file_web_fetch.dart index c416adfa..0000189a 100644 --- a/packages/file_picker_web/lib/src/platform_file_web_fetch.dart +++ b/packages/file_picker_web/lib/src/platform_file_web_fetch.dart @@ -1,29 +1,16 @@ import 'dart:js_interop'; +import 'dart:math'; import 'dart:typed_data'; +import 'package:web/web.dart'; + @JS('fetch') external JSPromise _fetchJs(JSString url); /// Interop extension type representing a Web `Response` JS object. extension type _Response(JSObject _) implements JSObject { external JSPromise arrayBuffer(); - external JSObject? get body; -} - -/// Interop extension type representing a Web `ReadableStream` JS object. -extension type _ReadableStream(JSObject _) implements JSObject { - external JSObject getReader(); -} - -/// Interop extension type representing a Web `ReadableStreamDefaultReader` JS object. -extension type _Reader(JSObject _) implements JSObject { - external JSPromise read(); -} - -/// Interop extension type representing a Web `ReadableStreamReadResult` JS object. -extension type _ReadResult(JSObject _) implements JSObject { - external bool get done; - external JSUint8Array? get value; + external JSPromise blob(); } /// Fetches the bytes of a web-only path (`blob:` or `data:` URL). @@ -64,42 +51,21 @@ Stream? fetchStreamFromWebPath(String path) { return _streamFromWebPath(path); } -/// Reads a `blob:` or `data:` URL and emits its bytes as a stream. -/// -/// Uses `Response.body` (`ReadableStream`) when available; otherwise falls -/// back to a single in-memory `arrayBuffer()` chunk. -Stream _streamFromWebPath(String path) async* { - if (path.startsWith('data:')) { - final uriData = Uri.parse(path).data; - - if (uriData == null) { - throw FormatException('Invalid data: URL', path); - } - - yield uriData.contentAsBytes(); - return; - } +/// The size of the chunks emitted by [streamBlobInChunks]. +const int webStreamChunkSize = 1024 * 1024; +/// Reads a `blob:` or `data:` URL and emits its bytes in evenly sized chunks. +Stream _streamFromWebPath(String path) async* { final response = _Response(await _fetchJs(path.toJS).toDart); - final body = response.body; + yield* streamBlobInChunks(await response.blob().toDart); +} - // If there's no streaming body, fallback to arrayBuffer() - if (body == null) { - final buffer = await response.arrayBuffer().toDart; +/// Emits the content of [blob] in chunks of [webStreamChunkSize] bytes, +/// except the last one, which may be shorter. +Stream streamBlobInChunks(Blob blob) async* { + for (var start = 0; start < blob.size; start += webStreamChunkSize) { + final end = min(start + webStreamChunkSize, blob.size); + final buffer = await blob.slice(start, end).arrayBuffer().toDart; yield buffer.toDart.asUint8List(); - return; - } - - final readable = _ReadableStream(body); - final reader = _Reader(readable.getReader()); - - while (true) { - final result = _ReadResult(await reader.read().toDart); - if (result.done) break; - - final arr = result.value; - if (arr == null) break; - - yield arr.toDart; } } diff --git a/packages/file_picker_web/lib/src/web_platform_file.dart b/packages/file_picker_web/lib/src/web_platform_file.dart index d1747558..19c72772 100644 --- a/packages/file_picker_web/lib/src/web_platform_file.dart +++ b/packages/file_picker_web/lib/src/web_platform_file.dart @@ -94,7 +94,13 @@ base class WebPlatformFile extends PlatformFile { return xFile.readAsBytes(); } + /// The size of the chunks emitted by [readAsByteStream]. + static const int streamChunkSize = webStreamChunkSize; + /// Asynchronously opens a stream to read the file content in chunks. + /// + /// Every chunk is [streamChunkSize] bytes long except the last one, which + /// may be shorter, so consumers can rely on evenly sized buffers. @override Stream readAsByteStream() async* { final readStream = _readStream; diff --git a/packages/file_picker_web/pubspec.yaml b/packages/file_picker_web/pubspec.yaml index 1c9ed9cf..9feab408 100644 --- a/packages/file_picker_web/pubspec.yaml +++ b/packages/file_picker_web/pubspec.yaml @@ -1,6 +1,6 @@ name: file_picker_web description: Web platform implementation of the file_picker plugin, providing browser file selection, file streaming, and file loading. -version: 4.0.1 +version: 4.1.0 homepage: https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web repository: https://github.com/vicajilau/flutter_file_picker/tree/main/packages/file_picker_web topics: diff --git a/packages/file_picker_web/test/file_picker_web_pick_test.dart b/packages/file_picker_web/test/file_picker_web_pick_test.dart index 9fb704b1..86fdea20 100644 --- a/packages/file_picker_web/test/file_picker_web_pick_test.dart +++ b/packages/file_picker_web/test/file_picker_web_pick_test.dart @@ -13,8 +13,13 @@ import 'package:web/web.dart'; void main() { setUpAll(() => FilePickerWeb.registerWith(webPluginRegistrar)); - Future> pick(List files) { - final result = FilePickerPlatform.instance.pickFiles(); + Future> pickFiles( + List files, { + WebOptions webOptions = const WebOptions(), + }) { + final result = FilePickerPlatform.instance.pickFiles( + webOptions: webOptions, + ); final input = document.querySelector('flt-file-picker-inputs input')! @@ -33,16 +38,42 @@ void main() { File([utf8.encode(content).toJS].toJS, name); test('picked files point at a readable blob: URL', () async { - final files = await pick([fileWith(name: 'a.txt', content: 'hello')]); + final files = await pickFiles([fileWith(name: 'a.txt', content: 'hello')]); expect(files.single.uri.scheme, 'blob'); expect(utf8.decode(await files.single.readAsBytes()), 'hello'); }); test('an empty picked file still gets a blob: URL', () async { - final files = await pick([fileWith(name: 'empty.txt', content: '')]); + final files = await pickFiles([fileWith(name: 'empty.txt', content: '')]); expect(files.single.uri.scheme, 'blob'); expect(await files.single.readAsBytes(), isEmpty); }); + + Future expectEvenChunks(List files) async { + final chunks = await files.single.readAsByteStream().toList(); + expect(chunks.map((chunk) => chunk.length), [ + WebPlatformFile.streamChunkSize, + 10, + ]); + } + + String bigContent() => 'a' * (WebPlatformFile.streamChunkSize + 10); + + test('picked files stream evenly sized chunks', () async { + await expectEvenChunks( + await pickFiles([fileWith(name: 'big.txt', content: bigContent())]), + ); + }); + + test('picked files stream evenly sized chunks with withReadStream', () async { + await expectEvenChunks( + await pickFiles( + [fileWith(name: 'big.txt', content: bigContent())], + // ignore: deprecated_member_use_from_same_package + webOptions: const FilePickerWebOptions(withReadStream: true), + ), + ); + }); } diff --git a/packages/file_picker_web/test/web_platform_file_read_test.dart b/packages/file_picker_web/test/web_platform_file_read_test.dart index e25c0d46..2114f64c 100644 --- a/packages/file_picker_web/test/web_platform_file_read_test.dart +++ b/packages/file_picker_web/test/web_platform_file_read_test.dart @@ -59,4 +59,31 @@ void main() { final chunks = await fileAt(url).readAsByteStream().toList(); expect(chunks.expand((chunk) => chunk).toList(), content); }); + + test('readAsByteStream() emits evenly sized chunks', () async { + const size = + WebPlatformFile.streamChunkSize * 2 + + WebPlatformFile.streamChunkSize ~/ 2; + // 251 is prime, so the byte pattern does not line up with the chunk + // boundaries (a multiple of 256) and swapped or repeated chunks still fail. + final large = Uint8List.fromList(List.generate(size, (i) => i % 251)); + final url = URL.createObjectURL(Blob([large.toJS].toJS)); + addTearDown(() => URL.revokeObjectURL(url)); + + final chunks = await fileAt(url).readAsByteStream().toList(); + + expect(chunks.map((chunk) => chunk.length), [ + WebPlatformFile.streamChunkSize, + WebPlatformFile.streamChunkSize, + WebPlatformFile.streamChunkSize ~/ 2, + ]); + expect(chunks.expand((chunk) => chunk).toList(), large); + }); + + test('readAsByteStream() emits nothing for an empty file', () async { + final url = URL.createObjectURL(Blob([].toJS)); + addTearDown(() => URL.revokeObjectURL(url)); + + expect(await fileAt(url).readAsByteStream().toList(), isEmpty); + }); }