Skip to content

Commit

Permalink
[image_picker_web] Listens to file input cancel event. (#4453)
Browse files Browse the repository at this point in the history
## Changes

This PR listens to the `cancel` event from the `input type=file` used by the web implementation of the image_picker plugin, so apps don't end up endlessly awaiting for a file that will never come **in modern browsers** (Chrome 113, Safari 16.4, or newer). _Same API as #3683

Additionally, this PR:

* Removes all code and tests mentioning `PickedFile`. (Deprecated years ago, and unused since #4285) **(Breaking change)**
* Updates README to mention `XFile` which is the current return type of the package.
* Updates the dependency on `image_picker_platform_interface` to `^2.9.0`.
  * Implements all non-deprecated methods from the interface, and makes deprecated methods use the fresh ones.
  * Updates tests.

### Issues

* Fixes flutter/flutter#92176

### Testing

* Added integration testing coverage for the 'cancel' event.
* Tested manually in Chrome with the example app running on web.
  • Loading branch information
ditman authored Aug 4, 2023
1 parent bf8e503 commit ce53da1
Show file tree
Hide file tree
Showing 5 changed files with 303 additions and 189 deletions.
7 changes: 7 additions & 0 deletions packages/image_picker/image_picker_for_web/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,10 @@
## 3.0.0

* **BREAKING CHANGE:** Removes all code and tests mentioning `PickedFile`.
* Listens to `cancel` event on file selection. When the selection is canceled:
* `Future<XFile?>` methods return `null`
* `Future<List<XFile>>` methods return an empty list.

## 2.2.0

* Adds `getMedia` method.
Expand Down
46 changes: 25 additions & 21 deletions packages/image_picker/image_picker_for_web/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,12 @@ A web implementation of [`image_picker`][1].

## Limitations on the web platform

Since Web Browsers don't offer direct access to their users' file system,
this plugin provides a `PickedFile` abstraction to make access uniform
across platforms.
### `XFile`

The web version of the plugin puts network-accessible URIs as the `path`
in the returned `PickedFile`.
This plugin uses `XFile` objects to abstract files picked/created by the user.

### URL.createObjectURL()

The `PickedFile` object in web is backed by [`URL.createObjectUrl` Web API](https://developer.mozilla.org/en-US/docs/Web/API/URL/createObjectURL),
which is reasonably well supported across all browsers:

![Data on support for the bloburls feature across the major browsers from caniuse.com](https://caniuse.bitsofco.de/image/bloburls.png)

However, the returned `path` attribute of the `PickedFile` points to a `network` resource, and not a
local path in your users' drive. See **Use the plugin** below for some examples on how to use this
return value in a cross-platform way.
Read more about `XFile` on the web in
[`package:cross_file`'s README](https://pub.dev/packages/cross_file).

### input file "accept"

Expand All @@ -42,11 +31,26 @@ In order to "take a photo", some mobile browsers offer a [`capture` attribute](h
Each browser may implement `capture` any way they please, so it may (or may not) make a
difference in your users' experience.

### pickImage()
The arguments `maxWidth`, `maxHeight` and `imageQuality` are not supported for gif images.
The argument `imageQuality` only works for jpeg and webp images.
### input file "cancel"

The [`cancel` event](https://caniuse.com/mdn-api_htmlinputelement_cancel_event)
used by the plugin to detect when users close the file selector without picking
a file is relatively new, and will only work in recent browsers.

### `ImagePickerOptions` support

The `ImagePickerOptions` configuration object allows passing resize (`maxWidth`,
`maxHeight`) and quality (`imageQuality`) parameters to some methods of this
plugin, which in other platforms control how selected images are resized or
re-encoded.

On the web:

* `maxWidth`, `maxHeight` and `imageQuality` are not supported for `gif` images.
* `imageQuality` only affects `jpg` and `webp` images.

### `getVideo()`

### pickVideo()
The argument `maxDuration` is not supported on the web.

## Usage
Expand All @@ -65,8 +69,8 @@ should add it to your `pubspec.yaml` as usual.

You should be able to use `package:image_picker` _almost_ as normal.

Once the user has picked a file, the returned `PickedFile` instance will contain a
`network`-accessible URL (pointing to a location within the browser).
Once the user has picked a file, the returned `XFile` instance will contain a
`network`-accessible `Blob` URL (pointing to a location within the browser).

The instance will also let you retrieve the bytes of the selected file across all platforms.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,9 @@ void main() {
plugin = ImagePickerPlugin();
});

testWidgets('Can select a file (Deprecated)', (WidgetTester tester) async {
testWidgets('getImageFromSource can select a file', (
WidgetTester _,
) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement();

final ImagePickerPluginTestOverrides overrides =
Expand All @@ -44,29 +46,9 @@ void main() {
final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);

// Init the pick file dialog...
final Future<PickedFile> file = plugin.pickFile();

// Mock the browser behavior of selecting a file...
mockInput.dispatchEvent(html.Event('change'));

// Now the file should be available
expect(file, completes);
// And readable
expect((await file).readAsBytes(), completion(isNotEmpty));
});

testWidgets('Can select a file', (WidgetTester tester) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement();

final ImagePickerPluginTestOverrides overrides =
ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);

final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);

// Init the pick file dialog...
final Future<XFile> image = plugin.getImage(source: ImageSource.camera);
final Future<XFile?> image = plugin.getImageFromSource(
source: ImageSource.camera,
);

// Mock the browser behavior of selecting a file...
mockInput.dispatchEvent(html.Event('change'));
Expand All @@ -75,8 +57,9 @@ void main() {
expect(image, completes);

// And readable
final XFile file = await image;
expect(file.readAsBytes(), completion(isNotEmpty));
final XFile? file = await image;
expect(file, isNotNull);
expect(file!.readAsBytes(), completion(isNotEmpty));
expect(file.name, textFile.name);
expect(file.length(), completion(textFile.size));
expect(file.mimeType, textFile.type);
Expand All @@ -87,8 +70,9 @@ void main() {
));
});

testWidgets('getMultiImage can select multiple files',
(WidgetTester tester) async {
testWidgets('getMultiImageWithOptions can select multiple files', (
WidgetTester _,
) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement();

final ImagePickerPluginTestOverrides overrides =
Expand All @@ -100,7 +84,7 @@ void main() {
final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides);

// Init the pick file dialog...
final Future<List<XFile>> files = plugin.getMultiImage();
final Future<List<XFile>> files = plugin.getMultiImageWithOptions();

// Mock the browser behavior of selecting a file...
mockInput.dispatchEvent(html.Event('change'));
Expand All @@ -118,8 +102,7 @@ void main() {
expect(secondFile.length(), completion(secondTextFile.size));
});

testWidgets('getMedia can select multiple files',
(WidgetTester tester) async {
testWidgets('getMedia can select multiple files', (WidgetTester _) async {
final html.FileUploadInputElement mockInput = html.FileUploadInputElement();

final ImagePickerPluginTestOverrides overrides =
Expand Down Expand Up @@ -150,7 +133,72 @@ void main() {
expect(secondFile.length(), completion(secondTextFile.size));
});

// There's no good way of detecting when the user has "aborted" the selection.
group('cancel event', () {
late html.FileUploadInputElement mockInput;
late ImagePickerPluginTestOverrides overrides;
late ImagePickerPlugin plugin;

setUp(() {
mockInput = html.FileUploadInputElement();
overrides = ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);
plugin = ImagePickerPlugin(overrides: overrides);
});

void mockCancel() {
mockInput.dispatchEvent(html.Event('cancel'));
}

testWidgets('getFiles - returns empty list', (WidgetTester _) async {
final Future<List<XFile>> files = plugin.getFiles();
mockCancel();

expect(files, completes);
expect(await files, isEmpty);
});

testWidgets('getMedia - returns empty list', (WidgetTester _) async {
final Future<List<XFile>?> files = plugin.getMedia(
options: const MediaOptions(
allowMultiple: true,
));
mockCancel();

expect(files, completes);
expect(await files, isEmpty);
});

testWidgets('getMultiImageWithOptions - returns empty list', (
WidgetTester _,
) async {
final Future<List<XFile>?> files = plugin.getMultiImageWithOptions();
mockCancel();

expect(files, completes);
expect(await files, isEmpty);
});

testWidgets('getImageFromSource - returns null', (WidgetTester _) async {
final Future<XFile?> file = plugin.getImageFromSource(
source: ImageSource.gallery,
);
mockCancel();

expect(file, completes);
expect(await file, isNull);
});

testWidgets('getVideo - returns null', (WidgetTester _) async {
final Future<XFile?> file = plugin.getVideo(
source: ImageSource.gallery,
);
mockCancel();

expect(file, completes);
expect(await file, isNull);
});
});

testWidgets('computeCaptureAttribute', (WidgetTester tester) async {
expect(
Expand Down Expand Up @@ -208,4 +256,102 @@ void main() {
expect(input.attributes, contains('multiple'));
});
});

group('Deprecated methods', () {
late html.FileUploadInputElement mockInput;
late ImagePickerPluginTestOverrides overrides;
late ImagePickerPlugin plugin;

setUp(() {
mockInput = html.FileUploadInputElement();
overrides = ImagePickerPluginTestOverrides()
..createInputElement = ((_, __) => mockInput)
..getMultipleFilesFromInput = ((_) => <html.File>[textFile]);
plugin = ImagePickerPlugin(overrides: overrides);
});

void mockCancel() {
mockInput.dispatchEvent(html.Event('cancel'));
}

void mockChange() {
mockInput.dispatchEvent(html.Event('change'));
}

group('getImage', () {
testWidgets('can select a file', (WidgetTester _) async {
// ignore: deprecated_member_use
final Future<XFile?> image = plugin.getImage(
source: ImageSource.camera,
);

// Mock the browser behavior when selecting a file...
mockChange();

// Now the file should be available
expect(image, completes);

// And readable
final XFile? file = await image;
expect(file, isNotNull);
expect(file!.readAsBytes(), completion(isNotEmpty));
expect(file.name, textFile.name);
expect(file.length(), completion(textFile.size));
expect(file.mimeType, textFile.type);
expect(
file.lastModified(),
completion(
DateTime.fromMillisecondsSinceEpoch(textFile.lastModified!),
));
});

testWidgets('returns null when canceled', (WidgetTester _) async {
// ignore: deprecated_member_use
final Future<XFile?> file = plugin.getImage(
source: ImageSource.gallery,
);
mockCancel();

expect(file, completes);
expect(await file, isNull);
});
});

group('getMultiImage', () {
testWidgets('can select multiple files', (WidgetTester _) async {
// Override the returned files...
overrides.getMultipleFilesFromInput =
(_) => <html.File>[textFile, secondTextFile];

// ignore: deprecated_member_use
final Future<List<XFile>> files = plugin.getMultiImage();

// Mock the browser behavior of selecting a file...
mockChange();

// Now the file should be available
expect(files, completes);

// And readable
expect((await files).first.readAsBytes(), completion(isNotEmpty));

// Peek into the second file...
final XFile secondFile = (await files).elementAt(1);
expect(secondFile.readAsBytes(), completion(isNotEmpty));
expect(secondFile.name, secondTextFile.name);
expect(secondFile.length(), completion(secondTextFile.size));
});

testWidgets('returns an empty list when canceled', (
WidgetTester _,
) async {
// ignore: deprecated_member_use
final Future<List<XFile>?> files = plugin.getMultiImage();
mockCancel();

expect(files, completes);
expect(await files, isEmpty);
});
});
});
}
Loading

0 comments on commit ce53da1

Please sign in to comment.