diff --git a/packages/file_selector/file_selector_web/CHANGELOG.md b/packages/file_selector/file_selector_web/CHANGELOG.md new file mode 100644 index 000000000000..cf87cfec36fd --- /dev/null +++ b/packages/file_selector/file_selector_web/CHANGELOG.md @@ -0,0 +1,3 @@ +# 0.7.0 + +- Initial open-source release. diff --git a/packages/file_selector/file_selector_web/LICENSE b/packages/file_selector/file_selector_web/LICENSE new file mode 100644 index 000000000000..2c91f1438173 --- /dev/null +++ b/packages/file_selector/file_selector_web/LICENSE @@ -0,0 +1,25 @@ +Copyright 2020 The Flutter Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without modification, +are permitted provided that the following conditions are met: + + * Redistributions of source code must retain the above copyright + notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above + copyright notice, this list of conditions and the following + disclaimer in the documentation and/or other materials provided + with the distribution. + * Neither the name of Google Inc. nor the names of its + contributors may be used to endorse or promote products derived + from this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND +ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED +WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR +ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES +(INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; +LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON +ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS +SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. \ No newline at end of file diff --git a/packages/file_selector/file_selector_web/README.md b/packages/file_selector/file_selector_web/README.md new file mode 100644 index 000000000000..36e0b446ffe8 --- /dev/null +++ b/packages/file_selector/file_selector_web/README.md @@ -0,0 +1,30 @@ +# file_picker_web + +The web implementation of [`file_picker`][1]. + +## Usage + +### Import the package +To use this plugin in your Flutter Web app, simply add it as a dependency in +your pubspec alongside the base `file_picker` plugin. + +_(This is only temporary: in the future we hope to make this package an +"endorsed" implementation of `file_picker`, so that it is automatically +included in your Flutter Web app when you depend on `package:file_picker`.)_ + +This is what the above means to your `pubspec.yaml`: + +```yaml +... +dependencies: + ... + file_picker: ^0.7.0 + file_picker_web: ^0.7.0 + ... +``` + +### Use the plugin +Once you have the `file_picker_web` dependency in your pubspec, you should +be able to use `package:file_picker` as normal. + +[1]: ../file_picker/file_picker diff --git a/packages/file_selector/file_selector_web/integration_test/dom_helper_test.dart b/packages/file_selector/file_selector_web/integration_test/dom_helper_test.dart new file mode 100644 index 000000000000..a942c0db10bf --- /dev/null +++ b/packages/file_selector/file_selector_web/integration_test/dom_helper_test.dart @@ -0,0 +1,116 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.9 + +import 'dart:html'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:file_selector_web/src/dom_helper.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; + +void main() { + group('FileSelectorWeb', () { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + DomHelper domHelper; + FileUploadInputElement input; + + FileList FileListItems(List files) { + final dataTransfer = DataTransfer(); + files.forEach(dataTransfer.items.add); + return dataTransfer.files; + } + + void setFilesAndTriggerChange(List files) { + input.files = FileListItems(files); + input.dispatchEvent(Event('change')); + } + + setUp(() { + domHelper = DomHelper(); + input = FileUploadInputElement(); + }); + + group('getFiles', () { + final mockFile1 = File(['123456'], 'file1.txt'); + final mockFile2 = File([], 'file2.txt'); + + testWidgets('works', (_) async { + final Future> futureFiles = domHelper.getFiles( + input: input, + ); + + setFilesAndTriggerChange([mockFile1, mockFile2]); + + final List files = await futureFiles; + + expect(files.length, 2); + + expect(files[0].name, 'file1.txt'); + expect(await files[0].length(), 6); + expect(await files[0].readAsString(), '123456'); + expect(await files[0].lastModified(), isNotNull); + + expect(files[1].name, 'file2.txt'); + expect(await files[1].length(), 0); + expect(await files[1].readAsString(), ''); + expect(await files[1].lastModified(), isNotNull); + }); + + testWidgets('works multiple times', (_) async { + Future> futureFiles; + List files; + + // It should work the first time + futureFiles = domHelper.getFiles(input: input); + setFilesAndTriggerChange([mockFile1]); + + files = await futureFiles; + + expect(files.length, 1); + expect(files.first.name, mockFile1.name); + + // The same input should work more than once + futureFiles = domHelper.getFiles(input: input); + setFilesAndTriggerChange([mockFile2]); + + files = await futureFiles; + + expect(files.length, 1); + expect(files.first.name, mockFile2.name); + }); + + testWidgets('sets the attributes and clicks it', (_) async { + final accept = '.jpg,.png'; + final multiple = true; + bool wasClicked = false; + + //ignore: unawaited_futures + input.onClick.first.then((_) => wasClicked = true); + + final futureFile = domHelper.getFiles( + accept: accept, + multiple: multiple, + input: input, + ); + + expect(input.matchesWithAncestors('body'), true); + expect(input.accept, accept); + expect(input.multiple, multiple); + expect( + wasClicked, + true, + reason: + 'The should be clicked otherwise no dialog will be shown', + ); + + setFilesAndTriggerChange([]); + await futureFile; + + // It should be already removed from the DOM after the file is resolved. + expect(input.parent, isNull); + }); + }); + }); +} diff --git a/packages/file_selector/file_selector_web/integration_test/file_selector_web_test.dart b/packages/file_selector/file_selector_web/integration_test/file_selector_web_test.dart new file mode 100644 index 000000000000..abd31dd9fcc6 --- /dev/null +++ b/packages/file_selector/file_selector_web/integration_test/file_selector_web_test.dart @@ -0,0 +1,89 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.9 + +import 'dart:typed_data'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_web/file_selector_web.dart'; +import 'package:file_selector_web/src/dom_helper.dart'; + +void main() { + group('FileSelectorWeb', () { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + MockDomHelper mockDomHelper; + FileSelectorWeb plugin; + + setUp(() { + mockDomHelper = MockDomHelper(); + plugin = FileSelectorWeb(domHelper: mockDomHelper); + }); + + group('openFile', () { + final mockFile = createXFile('1001', 'identity.png'); + + testWidgets('works', (WidgetTester _) async { + final typeGroup = XTypeGroup( + label: 'images', + extensions: ['jpg', 'jpeg'], + mimeTypes: ['image/png'], + webWildCards: ['image/*'], + ); + + when(mockDomHelper.getFiles( + accept: '.jpg,.jpeg,image/png,image/*', + multiple: false, + )).thenAnswer((_) async => [mockFile]); + + final file = await plugin.openFile(acceptedTypeGroups: [typeGroup]); + + expect(file.name, mockFile.name); + expect(await file.length(), 4); + expect(await file.readAsString(), '1001'); + expect(await file.lastModified(), isNotNull); + }); + }); + + group('openFiles', () { + final mockFile1 = createXFile('123456', 'file1.txt'); + final mockFile2 = createXFile('', 'file2.txt'); + + testWidgets('works', (WidgetTester _) async { + final typeGroup = XTypeGroup( + label: 'files', + extensions: ['.txt'], + ); + + when(mockDomHelper.getFiles( + accept: '.txt', + multiple: true, + )).thenAnswer((_) async => [mockFile1, mockFile2]); + + final files = await plugin.openFiles(acceptedTypeGroups: [typeGroup]); + + expect(files.length, 2); + + expect(files[0].name, mockFile1.name); + expect(await files[0].length(), 6); + expect(await files[0].readAsString(), '123456'); + expect(await files[0].lastModified(), isNotNull); + + expect(files[1].name, mockFile2.name); + expect(await files[1].length(), 0); + expect(await files[1].readAsString(), ''); + expect(await files[1].lastModified(), isNotNull); + }); + }); + }); +} + +class MockDomHelper extends Mock implements DomHelper {} + +XFile createXFile(String content, String name) { + final data = Uint8List.fromList(content.codeUnits); + return XFile.fromData(data, name: name, lastModified: DateTime.now()); +} diff --git a/packages/file_selector/file_selector_web/lib/file_selector_web.dart b/packages/file_selector/file_selector_web/lib/file_selector_web.dart new file mode 100644 index 000000000000..48f57ee880c8 --- /dev/null +++ b/packages/file_selector/file_selector_web/lib/file_selector_web.dart @@ -0,0 +1,74 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'package:meta/meta.dart'; +import 'package:flutter_web_plugins/flutter_web_plugins.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_web/src/dom_helper.dart'; +import 'package:file_selector_web/src/utils.dart'; + +/// The web implementation of [FileSelectorPlatform]. +/// +/// This class implements the `package:file_selector` functionality for the web. +class FileSelectorWeb extends FileSelectorPlatform { + final _domHelper; + + /// Registers this class as the default instance of [FileSelectorPlatform]. + static void registerWith(Registrar registrar) { + FileSelectorPlatform.instance = FileSelectorWeb(); + } + + /// Default constructor, initializes _domHelper that we can use + /// to interact with the DOM. + /// overrides parameter allows for testing to override functions + FileSelectorWeb({@visibleForTesting DomHelper domHelper}) + : _domHelper = domHelper ?? DomHelper(); + + @override + Future openFile({ + List acceptedTypeGroups, + String initialDirectory, + String confirmButtonText, + }) async { + final files = await _openFiles(acceptedTypeGroups: acceptedTypeGroups); + return files.first; + } + + @override + Future> openFiles({ + List acceptedTypeGroups, + String initialDirectory, + String confirmButtonText, + }) async { + return _openFiles(acceptedTypeGroups: acceptedTypeGroups, multiple: true); + } + + @override + Future getSavePath({ + List acceptedTypeGroups, + String initialDirectory, + String suggestedName, + String confirmButtonText, + }) async => + null; + + @override + Future getDirectoryPath({ + String initialDirectory, + String confirmButtonText, + }) async => + null; + + Future> _openFiles({ + List acceptedTypeGroups, + bool multiple = false, + }) async { + final accept = acceptedTypesToString(acceptedTypeGroups); + return _domHelper.getFiles( + accept: accept, + multiple: multiple, + ); + } +} diff --git a/packages/file_selector/file_selector_web/lib/src/dom_helper.dart b/packages/file_selector/file_selector_web/lib/src/dom_helper.dart new file mode 100644 index 000000000000..a965cebe97f9 --- /dev/null +++ b/packages/file_selector/file_selector_web/lib/src/dom_helper.dart @@ -0,0 +1,63 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:async'; +import 'dart:html'; +import 'package:meta/meta.dart'; +import 'package:flutter/services.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; + +/// Class to manipulate the DOM with the intention of reading files from it. +class DomHelper { + final _container = Element.tag('file-selector'); + + /// Default constructor, initializes the container DOM element. + DomHelper() { + final body = querySelector('body'); + body.children.add(_container); + } + + /// Sets the attributes and waits for a file to be selected. + Future> getFiles({ + String accept = '', + bool multiple = false, + @visibleForTesting FileUploadInputElement input, + }) { + final Completer> _completer = Completer(); + input = input ?? FileUploadInputElement(); + + _container.children.add( + input + ..accept = accept + ..multiple = multiple, + ); + + input.onChange.first.then((_) { + final List files = input.files.map(_convertFileToXFile).toList(); + input.remove(); + _completer.complete(files); + }); + + input.onError.first.then((event) { + final ErrorEvent error = event; + final platformException = PlatformException( + code: error.type, + message: error.message, + ); + input.remove(); + _completer.completeError(platformException); + }); + + input.click(); + + return _completer.future; + } + + XFile _convertFileToXFile(File file) => XFile( + Url.createObjectUrl(file), + name: file.name, + length: file.size, + lastModified: DateTime.fromMillisecondsSinceEpoch(file.lastModified), + ); +} diff --git a/packages/file_selector/file_selector_web/lib/src/utils.dart b/packages/file_selector/file_selector_web/lib/src/utils.dart new file mode 100644 index 000000000000..4ddd7ddcbda5 --- /dev/null +++ b/packages/file_selector/file_selector_web/lib/src/utils.dart @@ -0,0 +1,38 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; + +/// Convert list of XTypeGroups to a comma-separated string +String acceptedTypesToString(List acceptedTypes) { + if (acceptedTypes == null) return ''; + final List allTypes = []; + for (final group in acceptedTypes) { + _assertTypeGroupIsValid(group); + if (group.extensions != null) { + allTypes.addAll(group.extensions.map(_normalizeExtension)); + } + if (group.mimeTypes != null) { + allTypes.addAll(group.mimeTypes); + } + if (group.webWildCards != null) { + allTypes.addAll(group.webWildCards); + } + } + return allTypes.join(','); +} + +/// Make sure that at least one of its fields is populated. +void _assertTypeGroupIsValid(XTypeGroup group) { + assert( + !((group.extensions == null || group.extensions.isEmpty) && + (group.mimeTypes == null || group.mimeTypes.isEmpty) && + (group.webWildCards == null || group.webWildCards.isEmpty)), + 'At least one of extensions / mimeTypes / webWildCards is required for web.'); +} + +/// Append a dot at the beggining if it is not there png -> .png +String _normalizeExtension(String ext) { + return ext.isNotEmpty && ext[0] != '.' ? '.' + ext : ext; +} diff --git a/packages/file_selector/file_selector_web/pubspec.yaml b/packages/file_selector/file_selector_web/pubspec.yaml new file mode 100644 index 000000000000..c8e0eef56276 --- /dev/null +++ b/packages/file_selector/file_selector_web/pubspec.yaml @@ -0,0 +1,32 @@ +name: file_selector_web +description: Web platform implementation of file_selector +homepage: https://github.com/flutter/plugins/tree/master/packages/file_selector/file_selector_web +version: 0.7.0 + +flutter: + plugin: + platforms: + web: + pluginClass: FileSelectorWeb + fileName: file_selector_web.dart + +dependencies: + file_selector_platform_interface: ^1.0.2 + platform_detect: ^1.4.0 + flutter: + sdk: flutter + flutter_web_plugins: + sdk: flutter + meta: ^1.1.7 + +dev_dependencies: + flutter_test: + sdk: flutter + mockito: ^4.1.1 + pedantic: ^1.8.0 + integration_test: + path: ../../integration_test + +environment: + sdk: ">=2.2.0 <3.0.0" + flutter: ">=1.10.0" diff --git a/packages/file_selector/file_selector_web/run_integration_test b/packages/file_selector/file_selector_web/run_integration_test new file mode 100755 index 000000000000..c9f547a4f7d7 --- /dev/null +++ b/packages/file_selector/file_selector_web/run_integration_test @@ -0,0 +1,17 @@ +#!/usr/bin/bash + +if pgrep -lf chromedriver > /dev/null; then + echo "chromedriver is running." + + if [ $# -eq 0 ]; then + echo "No target specified, running all tests..." + find integration_test/ -iname *_test.dart | xargs -n1 -i -t flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target='{}' + else + echo "Running test target: $1..." + set -x + flutter drive -d web-server --web-port=7357 --browser-name=chrome --driver=test_driver/integration_test.dart --target=$1 + fi + + else + echo "chromedriver is not running." +fi diff --git a/packages/file_selector/file_selector_web/test/utils_test.dart b/packages/file_selector/file_selector_web/test/utils_test.dart new file mode 100644 index 000000000000..9fa187eede5b --- /dev/null +++ b/packages/file_selector/file_selector_web/test/utils_test.dart @@ -0,0 +1,59 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +// @dart = 2.9 + +import 'package:flutter_test/flutter_test.dart'; +import 'package:file_selector_web/src/utils.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; + +void main() { + group('FileSelectorWeb utils', () { + group('acceptedTypesToString', () { + test('works', () { + final List acceptedTypes = [ + XTypeGroup(label: 'images', webWildCards: ['images/*']), + XTypeGroup(label: 'jpgs', extensions: ['jpg', 'jpeg']), + XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), + ]; + final accepts = acceptedTypesToString(acceptedTypes); + expect(accepts, 'images/*,.jpg,.jpeg,image/png'); + }); + + test('works with an empty list', () { + final List acceptedTypes = []; + final accepts = acceptedTypesToString(acceptedTypes); + expect(accepts, ''); + }); + + test('works with extensions', () { + final List acceptedTypes = [ + XTypeGroup(label: 'jpgs', extensions: ['jpeg', 'jpg']), + XTypeGroup(label: 'pngs', extensions: ['png']), + ]; + final accepts = acceptedTypesToString(acceptedTypes); + expect(accepts, '.jpeg,.jpg,.png'); + }); + + test('works with mime types', () { + final List acceptedTypes = [ + XTypeGroup(label: 'jpgs', mimeTypes: ['image/jpeg', 'image/jpg']), + XTypeGroup(label: 'pngs', mimeTypes: ['image/png']), + ]; + final accepts = acceptedTypesToString(acceptedTypes); + expect(accepts, 'image/jpeg,image/jpg,image/png'); + }); + + test('works with web wild cards', () { + final List acceptedTypes = [ + XTypeGroup(label: 'images', webWildCards: ['image/*']), + XTypeGroup(label: 'audios', webWildCards: ['audio/*']), + XTypeGroup(label: 'videos', webWildCards: ['video/*']), + ]; + final accepts = acceptedTypesToString(acceptedTypes); + expect(accepts, 'image/*,audio/*,video/*'); + }); + }); + }); +} diff --git a/packages/file_selector/file_selector_web/test_driver/integration_test.dart b/packages/file_selector/file_selector_web/test_driver/integration_test.dart new file mode 100644 index 000000000000..44d6ed9c64bc --- /dev/null +++ b/packages/file_selector/file_selector_web/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// Copyright 2020 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver();