[image_picker_web] Listens to file input cancel event. (#4453)

## 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 https://github.com/flutter/packages/pull/3683._

Additionally, this PR:

* Removes all code and tests mentioning `PickedFile`. (Deprecated years ago, and unused since https://github.com/flutter/packages/pull/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 https://github.com/flutter/flutter/issues/92176

### Testing

* Added integration testing coverage for the 'cancel' event.
* Tested manually in Chrome with the example app running on web.
This commit is contained in:
David Iglesias
2023-08-04 08:08:05 -07:00
committed by GitHub
parent bf8e503898
commit ce53da1bd7
5 changed files with 304 additions and 190 deletions

View File

@ -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.

View File

@ -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"
@ -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
@ -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.

View File

@ -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 =
@ -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'));
@ -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);
@ -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 =
@ -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'));
@ -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 =
@ -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(
@ -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);
});
});
});
}

View File

@ -42,102 +42,47 @@ class ImagePickerPlugin extends ImagePickerPlatform {
ImagePickerPlatform.instance = ImagePickerPlugin();
}
/// Returns a [PickedFile] with the image that was picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
/// Returns an [XFile] with the image that was picked, or `null` if no images were picked.
@override
Future<PickedFile> pickImage({
Future<XFile?> getImageFromSource({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) {
final String? capture =
computeCaptureAttribute(source, preferredCameraDevice);
return pickFile(accept: _kAcceptImageMimeType, capture: capture);
}
/// Returns a [PickedFile] containing the video that was picked.
///
/// The [source] argument controls where the video comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxDuration` argument is not supported on the web. If the argument is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
Future<PickedFile> pickVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
}) {
final String? capture =
computeCaptureAttribute(source, preferredCameraDevice);
return pickFile(accept: _kAcceptVideoMimeType, capture: capture);
}
/// Injects a file input with the specified accept+capture attributes, and
/// returns the PickedFile that the user selected locally.
///
/// `capture` is only supported in mobile browsers.
/// See https://caniuse.com/#feat=html-media-capture
@visibleForTesting
Future<PickedFile> pickFile({
String? accept,
String? capture,
}) {
final html.FileUploadInputElement input =
createInputElement(accept, capture) as html.FileUploadInputElement;
_injectAndActivate(input);
return _getSelectedFile(input);
}
/// Returns an [XFile] with the image that was picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
Future<XFile> getImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
ImagePickerOptions options = const ImagePickerOptions(),
}) async {
final String? capture =
computeCaptureAttribute(source, preferredCameraDevice);
computeCaptureAttribute(source, options.preferredCameraDevice);
final List<XFile> files = await getFiles(
accept: _kAcceptImageMimeType,
capture: capture,
);
return _imageResizer.resizeImageIfNeeded(
files.first,
maxWidth,
maxHeight,
imageQuality,
return files.isEmpty
? null
: _imageResizer.resizeImageIfNeeded(
files.first,
options.maxWidth,
options.maxHeight,
options.imageQuality,
);
}
/// Returns a [List<XFile>] with the images that were picked, if any.
@override
Future<List<XFile>> getMultiImageWithOptions({
MultiImagePickerOptions options = const MultiImagePickerOptions(),
}) async {
final List<XFile> images = await getFiles(
accept: _kAcceptImageMimeType,
multiple: true,
);
final Iterable<Future<XFile>> resized = images.map(
(XFile image) => _imageResizer.resizeImageIfNeeded(
image,
options.imageOptions.maxWidth,
options.imageOptions.maxHeight,
options.imageOptions.imageQuality,
),
);
return Future.wait<XFile>(resized);
}
/// Returns an [XFile] containing the video that was picked.
@ -153,7 +98,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
///
/// If no images were picked, the return value is null.
@override
Future<XFile> getVideo({
Future<XFile?> getVideo({
required ImageSource source,
CameraDevice preferredCameraDevice = CameraDevice.rear,
Duration? maxDuration,
@ -164,30 +109,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
accept: _kAcceptVideoMimeType,
capture: capture,
);
return files.first;
}
/// Injects a file input, and returns a list of XFile images that the user selected locally.
@override
Future<List<XFile>> getMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) async {
final List<XFile> images = await getFiles(
accept: _kAcceptImageMimeType,
multiple: true,
);
final Iterable<Future<XFile>> resized = images.map(
(XFile image) => _imageResizer.resizeImageIfNeeded(
image,
maxWidth,
maxHeight,
imageQuality,
),
);
return Future.wait<XFile>(resized);
return files.isEmpty ? null : files.first;
}
/// Injects a file input, and returns a list of XFile media that the user selected locally.
@ -239,6 +161,58 @@ class ImagePickerPlugin extends ImagePickerPlatform {
return _getSelectedXFiles(input);
}
// Deprecated methods follow...
/// Returns an [XFile] with the image that was picked.
///
/// The `source` argument controls where the image comes from. This can
/// be either [ImageSource.camera] or [ImageSource.gallery].
///
/// Note that the `maxWidth`, `maxHeight` and `imageQuality` arguments are not supported on the web. If any of these arguments is supplied, it'll be silently ignored by the web version of the plugin.
///
/// Use `preferredCameraDevice` to specify the camera to use when the `source` is [ImageSource.camera].
/// The `preferredCameraDevice` is ignored when `source` is [ImageSource.gallery]. It is also ignored if the chosen camera is not supported on the device.
/// Defaults to [CameraDevice.rear].
///
/// If no images were picked, the return value is null.
@override
@Deprecated('Use getImageFromSource instead.')
Future<XFile?> getImage({
required ImageSource source,
double? maxWidth,
double? maxHeight,
int? imageQuality,
CameraDevice preferredCameraDevice = CameraDevice.rear,
}) async {
return getImageFromSource(
source: source,
options: ImagePickerOptions(
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
preferredCameraDevice: preferredCameraDevice,
));
}
/// Injects a file input, and returns a list of XFile images that the user selected locally.
@override
@Deprecated('Use getMultiImageWithOptions instead.')
Future<List<XFile>> getMultiImage({
double? maxWidth,
double? maxHeight,
int? imageQuality,
}) async {
return getMultiImageWithOptions(
options: MultiImagePickerOptions(
imageOptions: ImageOptions(
maxWidth: maxWidth,
maxHeight: maxHeight,
imageQuality: imageQuality,
),
),
);
}
// DOM methods
/// Converts plugin configuration into a proper value for the `capture` attribute.
@ -267,29 +241,6 @@ class ImagePickerPlugin extends ImagePickerPlatform {
return input == null ? null : _getFilesFromInput(input);
}
/// Monitors an <input type="file"> and returns the selected file.
Future<PickedFile> _getSelectedFile(html.FileUploadInputElement input) {
final Completer<PickedFile> completer = Completer<PickedFile>();
// Observe the input until we can return something
input.onChange.first.then((html.Event event) {
final List<html.File>? files = _handleOnChangeEvent(event);
if (!completer.isCompleted && files != null) {
completer.complete(PickedFile(
html.Url.createObjectUrl(files.first),
));
}
});
input.onError.first.then((html.Event event) {
if (!completer.isCompleted) {
completer.completeError(event);
}
});
// Note that we don't bother detaching from these streams, since the
// "input" gets re-created in the DOM every time the user needs to
// pick a file.
return completer.future;
}
/// Monitors an <input type="file"> and returns the selected file(s).
Future<List<XFile>> _getSelectedXFiles(html.FileUploadInputElement input) {
final Completer<List<XFile>> completer = Completer<List<XFile>>();
@ -310,6 +261,11 @@ class ImagePickerPlugin extends ImagePickerPlatform {
}).toList());
}
});
input.addEventListener('cancel', (html.Event _) {
completer.complete(<XFile>[]);
});
input.onError.first.then((html.Event event) {
if (!completer.isCompleted) {
completer.completeError(event);
@ -361,6 +317,7 @@ class ImagePickerPlugin extends ImagePickerPlatform {
void _injectAndActivate(html.Element element) {
_target.children.clear();
_target.children.add(element);
// TODO(dit): Reimplement this with the showPicker() API, https://github.com/flutter/flutter/issues/130365
element.click();
}
}

View File

@ -2,7 +2,7 @@ name: image_picker_for_web
description: Web platform implementation of image_picker
repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_for_web
issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22
version: 2.2.0
version: 3.0.0
environment:
sdk: ">=2.18.0 <4.0.0"
@ -21,7 +21,7 @@ dependencies:
sdk: flutter
flutter_web_plugins:
sdk: flutter
image_picker_platform_interface: ^2.8.0
image_picker_platform_interface: ^2.9.0
mime: ^1.0.4
dev_dependencies: