Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions packages/file_picker_web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
34 changes: 2 additions & 32 deletions packages/file_picker_web/lib/src/file_picker_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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;

Expand Down Expand Up @@ -158,7 +158,7 @@ class FilePickerWeb extends FilePickerPlatform {
if (webOptions.withReadStream) {
return _createWebPlatformFile(
file: file,
readStream: _openFileReadStream(file),
readStream: streamBlobInChunks(file),
);
}

Expand Down Expand Up @@ -285,34 +285,4 @@ class FilePickerWeb extends FilePickerPlatform {
),
};
}

/// Opens a chunked byte stream reader for a web [File].
Stream<Uint8List> _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<JSArrayBuffer>()) {
yield (readerResult as JSArrayBuffer).toDart.asUint8List();
start += _readStreamChunkSize;
continue;
}

if (readerResult.isA<JSArray>()) {
yield Uint8List.fromList((readerResult as JSArray).toDart.cast<int>());
start += _readStreamChunkSize;
}
}
}
}
24 changes: 24 additions & 0 deletions packages/file_picker_web/lib/src/file_picker_web_options.dart
Original file line number Diff line number Diff line change
Expand Up @@ -7,24 +7,48 @@ 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.
final bool cancelUploadOnWindowBlur;

/// 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,
});
Expand Down
66 changes: 16 additions & 50 deletions packages/file_picker_web/lib/src/platform_file_web_fetch.dart
Original file line number Diff line number Diff line change
@@ -1,29 +1,16 @@
import 'dart:js_interop';
import 'dart:math';
import 'dart:typed_data';

import 'package:web/web.dart';

@JS('fetch')
external JSPromise<JSObject> _fetchJs(JSString url);

/// Interop extension type representing a Web `Response` JS object.
extension type _Response(JSObject _) implements JSObject {
external JSPromise<JSArrayBuffer> 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<JSObject> 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> blob();
}

/// Fetches the bytes of a web-only path (`blob:` or `data:` URL).
Expand Down Expand Up @@ -64,42 +51,21 @@ Stream<Uint8List>? 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<Uint8List> _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<Uint8List> _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<Uint8List> 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;
}
}
6 changes: 6 additions & 0 deletions packages/file_picker_web/lib/src/web_platform_file.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<Uint8List> readAsByteStream() async* {
final readStream = _readStream;
Expand Down
2 changes: 1 addition & 1 deletion packages/file_picker_web/pubspec.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
39 changes: 35 additions & 4 deletions packages/file_picker_web/test/file_picker_web_pick_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -13,8 +13,13 @@ import 'package:web/web.dart';
void main() {
setUpAll(() => FilePickerWeb.registerWith(webPluginRegistrar));

Future<List<PlatformFile>> pick(List<File> files) {
final result = FilePickerPlatform.instance.pickFiles();
Future<List<PlatformFile>> pickFiles(
List<File> files, {
WebOptions webOptions = const WebOptions(),
}) {
final result = FilePickerPlatform.instance.pickFiles(
webOptions: webOptions,
);

final input =
document.querySelector('flt-file-picker-inputs input')!
Expand All @@ -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<void> expectEvenChunks(List<PlatformFile> 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),
),
);
});
}
27 changes: 27 additions & 0 deletions packages/file_picker_web/test/web_platform_file_read_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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(<JSAny>[].toJS));
addTearDown(() => URL.revokeObjectURL(url));

expect(await fileAt(url).readAsByteStream().toList(), isEmpty);
});
}
Loading