From ce53da1bd7411f1d356706363fdb42c55d7c4cc8 Mon Sep 17 00:00:00 2001 From: David Iglesias Date: Fri, 4 Aug 2023 08:08:05 -0700 Subject: [PATCH] [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. --- .../image_picker_for_web/CHANGELOG.md | 7 + .../image_picker_for_web/README.md | 46 ++-- .../image_picker_for_web_test.dart | 210 +++++++++++++--- .../lib/image_picker_for_web.dart | 225 +++++++----------- .../image_picker_for_web/pubspec.yaml | 4 +- 5 files changed, 303 insertions(+), 189 deletions(-) diff --git a/packages/image_picker/image_picker_for_web/CHANGELOG.md b/packages/image_picker/image_picker_for_web/CHANGELOG.md index 8230dd7f130f..88f6159cef06 100644 --- a/packages/image_picker/image_picker_for_web/CHANGELOG.md +++ b/packages/image_picker/image_picker_for_web/CHANGELOG.md @@ -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` methods return `null` + * `Future>` methods return an empty list. + ## 2.2.0 * Adds `getMedia` method. diff --git a/packages/image_picker/image_picker_for_web/README.md b/packages/image_picker/image_picker_for_web/README.md index 4d8db43196f7..6583105ea37a 100644 --- a/packages/image_picker/image_picker_for_web/README.md +++ b/packages/image_picker/image_picker_for_web/README.md @@ -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. diff --git a/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart index fffbd6d0be90..5a3af7eab573 100644 --- a/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart +++ b/packages/image_picker/image_picker_for_web/example/integration_test/image_picker_for_web_test.dart @@ -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 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 = ((_) => [textFile]); - - final ImagePickerPlugin plugin = ImagePickerPlugin(overrides: overrides); - - // Init the pick file dialog... - final Future image = plugin.getImage(source: ImageSource.camera); + final Future 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> files = plugin.getMultiImage(); + final Future> 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 = ((_) => [textFile]); + plugin = ImagePickerPlugin(overrides: overrides); + }); + + void mockCancel() { + mockInput.dispatchEvent(html.Event('cancel')); + } + + testWidgets('getFiles - returns empty list', (WidgetTester _) async { + final Future> files = plugin.getFiles(); + mockCancel(); + + expect(files, completes); + expect(await files, isEmpty); + }); + + testWidgets('getMedia - returns empty list', (WidgetTester _) async { + final Future?> 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?> files = plugin.getMultiImageWithOptions(); + mockCancel(); + + expect(files, completes); + expect(await files, isEmpty); + }); + + testWidgets('getImageFromSource - returns null', (WidgetTester _) async { + final Future file = plugin.getImageFromSource( + source: ImageSource.gallery, + ); + mockCancel(); + + expect(file, completes); + expect(await file, isNull); + }); + + testWidgets('getVideo - returns null', (WidgetTester _) async { + final Future 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 = ((_) => [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 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 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 = + (_) => [textFile, secondTextFile]; + + // ignore: deprecated_member_use + final Future> 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?> files = plugin.getMultiImage(); + mockCancel(); + + expect(files, completes); + expect(await files, isEmpty); + }); + }); + }); } diff --git a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart index fb88c96a5942..b54b68ac550a 100644 --- a/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart +++ b/packages/image_picker/image_picker_for_web/lib/image_picker_for_web.dart @@ -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. - @override - Future pickImage({ - 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. + /// Returns an [XFile] with the image that was picked, or `null` if no images were picked. @override - Future pickVideo({ + Future getImageFromSource({ required ImageSource source, - CameraDevice preferredCameraDevice = CameraDevice.rear, - Duration? maxDuration, - }) { + ImagePickerOptions options = const ImagePickerOptions(), + }) async { 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 pickFile({ - String? accept, - String? capture, - }) { - final html.FileUploadInputElement input = - createInputElement(accept, capture) as html.FileUploadInputElement; - _injectAndActivate(input); - return _getSelectedFile(input); + computeCaptureAttribute(source, options.preferredCameraDevice); + final List files = await getFiles( + accept: _kAcceptImageMimeType, + capture: capture, + ); + return files.isEmpty + ? null + : _imageResizer.resizeImageIfNeeded( + files.first, + options.maxWidth, + options.maxHeight, + options.imageQuality, + ); } - /// 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. + /// Returns a [List] with the images that were picked, if any. @override - Future getImage({ - required ImageSource source, - double? maxWidth, - double? maxHeight, - int? imageQuality, - CameraDevice preferredCameraDevice = CameraDevice.rear, + Future> getMultiImageWithOptions({ + MultiImagePickerOptions options = const MultiImagePickerOptions(), }) async { - final String? capture = - computeCaptureAttribute(source, preferredCameraDevice); - final List files = await getFiles( + final List images = await getFiles( accept: _kAcceptImageMimeType, - capture: capture, + multiple: true, ); - return _imageResizer.resizeImageIfNeeded( - files.first, - maxWidth, - maxHeight, - imageQuality, + final Iterable> resized = images.map( + (XFile image) => _imageResizer.resizeImageIfNeeded( + image, + options.imageOptions.maxWidth, + options.imageOptions.maxHeight, + options.imageOptions.imageQuality, + ), ); + + return Future.wait(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 getVideo({ + Future 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> getMultiImage({ - double? maxWidth, - double? maxHeight, - int? imageQuality, - }) async { - final List images = await getFiles( - accept: _kAcceptImageMimeType, - multiple: true, - ); - final Iterable> resized = images.map( - (XFile image) => _imageResizer.resizeImageIfNeeded( - image, - maxWidth, - maxHeight, - imageQuality, - ), - ); - - return Future.wait(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 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> 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 and returns the selected file. - Future _getSelectedFile(html.FileUploadInputElement input) { - final Completer completer = Completer(); - // Observe the input until we can return something - input.onChange.first.then((html.Event event) { - final List? 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 and returns the selected file(s). Future> _getSelectedXFiles(html.FileUploadInputElement input) { final Completer> completer = Completer>(); @@ -310,6 +261,11 @@ class ImagePickerPlugin extends ImagePickerPlatform { }).toList()); } }); + + input.addEventListener('cancel', (html.Event _) { + completer.complete([]); + }); + 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(); } } diff --git a/packages/image_picker/image_picker_for_web/pubspec.yaml b/packages/image_picker/image_picker_for_web/pubspec.yaml index a61a5b838c30..d7e274bb594b 100644 --- a/packages/image_picker/image_picker_for_web/pubspec.yaml +++ b/packages/image_picker/image_picker_for_web/pubspec.yaml @@ -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: