diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart new file mode 100644 index 000000000000..ee52efef0a5e --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_mode.dart @@ -0,0 +1,12 @@ +// Copyright 2013 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. + +/// The kind of file dialog to show. +enum DialogMode { + /// Used for chosing files. + Open, + + /// Used for chosing a directory to save a file. + Save +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart new file mode 100644 index 000000000000..0f018d49cba1 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/dialog_wrapper.dart @@ -0,0 +1,197 @@ +// Copyright 2013 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:core'; +import 'dart:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:win32/win32.dart'; + +import 'dialog_mode.dart'; +import 'file_dialog_controller.dart'; +import 'ifile_dialog_controller_factory.dart'; +import 'ifile_dialog_factory.dart'; +import 'shell_win32_api.dart'; + +/// Wraps an IFileDialog, managing object lifetime as a scoped object and +/// providing a simplified API for interacting with it as needed for the plugin. +class DialogWrapper { + /// Creates a DialogWrapper using a [IFileDialogControllerFactory] and a [DialogMode]. + /// It is also responsible of creating a [IFileDialog]. + DialogWrapper(IFileDialogControllerFactory fileDialogControllerFactory, + IFileDialogFactory fileDialogFactory, this._dialogMode) + : _isOpenDialog = _dialogMode == DialogMode.Open { + try { + final IFileDialog dialog = fileDialogFactory.createInstace(_dialogMode); + _dialogController = fileDialogControllerFactory.createController(dialog); + _shellWin32Api = ShellWin32Api(); + } catch (ex) { + if (ex is WindowsException) { + _lastResult = ex.hr; + } + } + } + + /// Creates a DialogWrapper for testing purposes. + @visibleForTesting + DialogWrapper.withFakeDependencies(FileDialogController dialogController, + this._dialogMode, this._shellWin32Api) + : _isOpenDialog = _dialogMode == DialogMode.Open, + _dialogController = dialogController; + + int _lastResult = S_OK; + + final DialogMode _dialogMode; + + final bool _isOpenDialog; + + final String _allowAnyValue = 'Any'; + + final String _allowAnyExtension = '*.*'; + + late FileDialogController _dialogController; + + late ShellWin32Api _shellWin32Api; + + /// Returns the result of the last Win32 API call related to this object. + int get lastResult => _lastResult; + + /// Attempts to set the default folder for the dialog to [path], if it exists. + void setFolder(String path) { + if (path == null || path.isEmpty) { + return; + } + + using((Arena arena) { + final Pointer ptrGuid = GUIDFromString(IID_IShellItem); + final Pointer> ptrPath = arena>(); + _lastResult = + _shellWin32Api.createItemFromParsingName(path, ptrGuid, ptrPath); + + if (!SUCCEEDED(_lastResult)) { + return; + } + + _dialogController.setFolder(ptrPath.value); + }); + } + + /// Sets the file name that is initially shown in the dialog. + void setFileName(String name) { + _dialogController.setFileName(name); + } + + /// Sets the label of the confirmation button. + void setOkButtonLabel(String label) { + _dialogController.setOkButtonLabel(label); + } + + /// Adds the given options to the dialog's current [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html). + /// Both are bitfields. + void addOptions(int newOptions) { + using((Arena arena) { + final Pointer currentOptions = arena(); + _lastResult = _dialogController.getOptions(currentOptions); + if (!SUCCEEDED(_lastResult)) { + return; + } + currentOptions.value |= newOptions; + _lastResult = _dialogController.setOptions(currentOptions.value); + }); + } + + /// Sets the filters for allowed file types to select. + /// filters -> std::optional + void setFileTypeFilters(List filters) { + final Map filterSpecification = {}; + + if (filters.isEmpty) { + filterSpecification[_allowAnyValue] = _allowAnyExtension; + } else { + for (final XTypeGroup option in filters) { + final String? label = option.label; + if (option.allowsAny || option.extensions!.isEmpty) { + filterSpecification[label ?? _allowAnyValue] = _allowAnyExtension; + } else { + final String extensionsForLabel = option.extensions! + .map((String extension) => '*.$extension') + .join(';'); + filterSpecification[label ?? extensionsForLabel] = extensionsForLabel; + } + } + } + + using((Arena arena) { + final Pointer registerFilterSpecification = + arena(filterSpecification.length); + + int index = 0; + for (final String key in filterSpecification.keys) { + registerFilterSpecification[index] + ..pszName = TEXT(key) + ..pszSpec = TEXT(filterSpecification[key]!); + index++; + } + + _lastResult = _dialogController.setFileTypes( + filterSpecification.length, registerFilterSpecification); + }); + } + + /// Displays the dialog, and returns the selected files, or null on error. + List? show(int parentWindow) { + _lastResult = _dialogController.show(parentWindow); + if (!SUCCEEDED(_lastResult)) { + return null; + } + late List? files; + + using((Arena arena) { + final Pointer> shellItemArrayPtr = + arena>(); + final Pointer shellItemCountPtr = arena(); + final Pointer> shellItemPtr = + arena>(); + + files = + _getFilePathList(shellItemArrayPtr, shellItemCountPtr, shellItemPtr); + }); + return files; + } + + List? _getFilePathList( + Pointer> shellItemArrayPtr, + Pointer shellItemCountPtr, + Pointer> shellItemPtr) { + final List files = []; + if (_isOpenDialog) { + _lastResult = _dialogController.getResults(shellItemArrayPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + + final IShellItemArray shellItemResources = + IShellItemArray(shellItemArrayPtr.cast()); + _lastResult = shellItemResources.getCount(shellItemCountPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + for (int index = 0; index < shellItemCountPtr.value; index++) { + shellItemResources.getItemAt(index, shellItemPtr); + final IShellItem shellItem = IShellItem(shellItemPtr.cast()); + files.add(_shellWin32Api.getPathForShellItem(shellItem)); + } + } else { + _lastResult = _dialogController.getResult(shellItemPtr); + if (!SUCCEEDED(_lastResult)) { + return null; + } + final IShellItem shellItem = IShellItem(shellItemPtr.cast()); + files.add(_shellWin32Api.getPathForShellItem(shellItem)); + } + return files; + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart new file mode 100644 index 000000000000..65164eb9e40e --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller.dart @@ -0,0 +1,91 @@ +// Copyright 2013 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:ffi'; + +import 'package:win32/win32.dart'; + +import 'ifile_open_dialog_factory.dart'; + +/// A thin wrapper for IFileDialog to allow for faking and inspection in tests. +/// +/// Since this class defines the end of what can be unit tested, it should +/// contain as little logic as possible. +class FileDialogController { + /// Creates a controller managing [IFileDialog](https://pub.dev/documentation/win32/latest/winrt/IFileDialog-class.html). + /// It also receives an IFileOpenDialogFactory to construct [IFileOpenDialog] + /// instances. + FileDialogController( + IFileDialog fileDialog, IFileOpenDialogFactory iFileOpenDialogFactory) + : _fileDialog = fileDialog, + _iFileOpenDialogFactory = iFileOpenDialogFactory; + + /// The [IFileDialog] to work with. + final IFileDialog _fileDialog; + + /// The [IFileOpenDialogFactory] to work construc [IFileOpenDialog] instances. + final IFileOpenDialogFactory _iFileOpenDialogFactory; + + /// Sets the default folder for the dialog to [path]. It also returns the operation result. + int setFolder(Pointer path) { + return _fileDialog.setFolder(path); + } + + /// Sets the file [name] that is initially shown in the IFileDialog. It also returns the operation result. + int setFileName(String name) { + return _fileDialog.setFileName(TEXT(name)); + } + + /// Sets the allowed file type extensions in the IFileOpenDialog. It also returns the operation result. + int setFileTypes(int count, Pointer filters) { + return _fileDialog.setFileTypes(count, filters); + } + + /// Sets the label of the confirmation button. It also returns the operation result. It also returns the operation result. + int setOkButtonLabel(String text) { + return _fileDialog.setOkButtonLabel(TEXT(text)); + } + + /// Gets the IFileDialog's [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html), + /// which is a bitfield. It also returns the operation result. + int getOptions(Pointer outOptions) { + return _fileDialog.getOptions(outOptions); + } + + /// Sets the [options](https://pub.dev/documentation/win32/latest/winrt/FILEOPENDIALOGOPTIONS-class.html), + /// which is a bitfield, into the IFileDialog. It also returns the operation result. + int setOptions(int options) { + return _fileDialog.setOptions(options); + } + + /// Shows an IFileDialog using the given parent. It returns the operation result. + int show(int parent) { + return _fileDialog.show(parent); + } + + /// Return results from an IFileDialog. This should be used when selecting + /// single items. It also returns the operation result. + int getResult(Pointer> outItem) { + return _fileDialog.getResult(outItem); + } + + /// Return results from an IFileOpenDialog. This should be used when selecting + /// single items. This function will fail if the IFileDialog* provided to the + /// constructor was not an IFileOpenDialog instance, returning an E_FAIL + /// error. + int getResults(Pointer> outItems) { + IFileOpenDialog? fileOpenDialog; + try { + fileOpenDialog = _iFileOpenDialogFactory.from(_fileDialog); + return fileOpenDialog.getResults(outItems); + } catch (_) { + return E_FAIL; + } finally { + fileOpenDialog?.release(); + if (fileOpenDialog != null) { + free(fileOpenDialog.ptr); + } + } + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart new file mode 100644 index 000000000000..296c9ba4529f --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/file_dialog_controller_factory.dart @@ -0,0 +1,18 @@ +// Copyright 2013 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:win32/win32.dart'; + +import 'file_dialog_controller.dart'; +import 'ifile_dialog_controller_factory.dart'; +import 'ifile_open_dialog_factory.dart'; + +/// Implementation of FileDialogControllerFactory that makes standard +/// FileDialogController instances. +class FileDialogControllerFactory implements IFileDialogControllerFactory { + @override + FileDialogController createController(IFileDialog dialog) { + return FileDialogController(dialog, IFileOpenDialogFactory()); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart new file mode 100644 index 000000000000..63f081b82944 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_controller_factory.dart @@ -0,0 +1,14 @@ +// Copyright 2013 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:win32/win32.dart'; + +import 'file_dialog_controller.dart'; + +/// Interface for creating FileDialogControllers, to allow for dependency +/// injection. +abstract class IFileDialogControllerFactory { + /// Returns a FileDialogController to interact with the given [IFileDialog]. + FileDialogController createController(IFileDialog dialog); +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart new file mode 100644 index 000000000000..4a13d8731a33 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_dialog_factory.dart @@ -0,0 +1,19 @@ +// Copyright 2013 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:win32/win32.dart'; + +import 'dialog_mode.dart'; + +/// A factory for [IFileDialog] instances. +class IFileDialogFactory { + /// Creates the corresponding IFileDialog instace. The caller is responsible of releasing the resource. + IFileDialog createInstace(DialogMode dialogMode) { + if (dialogMode == DialogMode.Open) { + return FileOpenDialog.createInstance(); + } + + return FileSaveDialog.createInstance(); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart new file mode 100644 index 000000000000..fca0095db413 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/ifile_open_dialog_factory.dart @@ -0,0 +1,13 @@ +// Copyright 2013 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:win32/win32.dart'; + +/// A wrapper of the IFileOpenDialog interface to use its from function. +class IFileOpenDialogFactory { + /// Wraps the IFileOpenDialog from function. + IFileOpenDialog from(IFileDialog fileDialog) { + return IFileOpenDialog.from(fileDialog); + } +} diff --git a/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart new file mode 100644 index 000000000000..4390e6580aa0 --- /dev/null +++ b/packages/file_selector/file_selector_windows/lib/src/file_selector_dart/shell_win32_api.dart @@ -0,0 +1,36 @@ +// Copyright 2013 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:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:win32/win32.dart'; + +/// A thin wrapper for Win32 platform specific Shell methods. +/// +/// The only purpose of this class is to decouple specific Win32 Api call from the bussiness logic so it can be init tested in any environment. +class ShellWin32Api { + /// Creates and [initializes](https://learn.microsoft.com/en-us/windows/win32/api/shobjidl_core/nf-shobjidl_core-shcreateitemfromparsingname) a Shell item object from a parsing name. + /// If the directory doesn't exist it will return an error result. + int createItemFromParsingName(String initialDirectory, Pointer ptrGuid, + Pointer> ptrPath) { + return SHCreateItemFromParsingName( + TEXT(initialDirectory), nullptr, ptrGuid, ptrPath); + } + + /// Returns the path for [shellItem] as a UTF-8 string, or an empty string on + /// failure. + String getPathForShellItem(IShellItem shellItem) { + return using((Arena arena) { + final Pointer> ptrPath = arena>(); + + if (!SUCCEEDED( + shellItem.getDisplayName(SIGDN.SIGDN_FILESYSPATH, ptrPath.cast()))) { + return ''; + } + + return ptrPath.value.toDartString(); + }); + } +} diff --git a/packages/file_selector/file_selector_windows/pubspec.yaml b/packages/file_selector/file_selector_windows/pubspec.yaml index ee0701b3fd30..ef6d25d8f8de 100644 --- a/packages/file_selector/file_selector_windows/pubspec.yaml +++ b/packages/file_selector/file_selector_windows/pubspec.yaml @@ -18,9 +18,11 @@ flutter: dependencies: cross_file: ^0.3.1 + ffi: ^2.0.1 file_selector_platform_interface: ^2.2.0 flutter: sdk: flutter + win32: ^3.0.0 dev_dependencies: build_runner: 2.1.11 diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart new file mode 100644 index 000000000000..056075cbee02 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart @@ -0,0 +1,306 @@ +// Copyright 2013 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:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_mode.dart'; +import 'package:file_selector_windows/src/file_selector_dart/dialog_wrapper.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:file_selector_windows/src/file_selector_dart/shell_win32_api.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; +import 'package:win32/win32.dart'; + +import 'dialog_wrapper_test.mocks.dart'; + +@GenerateMocks([FileDialogController, ShellWin32Api]) +void main() { + const int defaultReturnValue = S_OK; + late final MockFileDialogController mockFileDialogController = + MockFileDialogController(); + late final MockShellWin32Api mockShellWin32Api = MockShellWin32Api(); + const DialogMode dialogMode = DialogMode.Open; + final DialogWrapper dialogWrapper = DialogWrapper.withFakeDependencies( + mockFileDialogController, dialogMode, mockShellWin32Api); + + setUp(() { + setDefaultMocks(mockFileDialogController, defaultReturnValue); + }); + + tearDown(() { + reset(mockFileDialogController); + reset(mockShellWin32Api); + }); + + test('setFileName should call dialog setFileName', () { + const String folderName = 'Documents'; + dialogWrapper.setFileName(folderName); + verify(mockFileDialogController.setFileName(folderName)).called(1); + }); + + test('setOkButtonLabel should call dialog setOkButtonLabel', () { + const String okButtonLabel = 'Confirm'; + dialogWrapper.setOkButtonLabel(okButtonLabel); + verify(mockFileDialogController.setOkButtonLabel(okButtonLabel)).called(1); + }); + + test('addOptions should call dialog getOptions and setOptions', () { + const int newOptions = FILEOPENDIALOGOPTIONS.FOS_NOREADONLYRETURN; + dialogWrapper.addOptions(newOptions); + verify(mockFileDialogController.getOptions(any)).called(1); + verify(mockFileDialogController.setOptions(newOptions)).called(1); + }); + + test('addOptions should not call setOptions if getOptions returns an error', + () { + const int options = FILEOPENDIALOGOPTIONS.FOS_NOREADONLYRETURN; + when(mockFileDialogController.getOptions(any)).thenReturn(E_FAIL); + dialogWrapper.addOptions(options); + verifyNever(mockFileDialogController.setOptions(any)); + }); + + test( + 'setFileTypeFilters should call setFileTypes with expected typeGroups count', + () { + final List typeGroups = [ + const XTypeGroup(extensions: ['jpg', 'png'], label: 'Images'), + const XTypeGroup(extensions: ['txt', 'json'], label: 'Text'), + ]; + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + }); + + test('setFileTypeFilters should call setFileTypes with Any by default', () { + const String expectedPszName = 'Any'; + const String expectedPszSpec = '*.*'; + final List typeGroups = []; + mockSetFileTypesConditions( + mockFileDialogController, expectedPszName, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with a label and default extensions', + () { + const String label = 'All files'; + const String expectedPszSpec = '*.*'; + final List typeGroups = [ + const XTypeGroup(label: label), + ]; + mockSetFileTypesConditions( + mockFileDialogController, label, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with both default label and extensions', + () { + const String defaultLabel = 'Any'; + const String expectedPszSpec = '*.*'; + final List typeGroups = [ + const XTypeGroup(), + ]; + mockSetFileTypesConditions( + mockFileDialogController, defaultLabel, expectedPszSpec); + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(1, any)).called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with specific labels and extensions', + () { + const String jpg = 'jpg'; + const String png = 'png'; + const String imageLabel = 'Image'; + const String txt = 'txt'; + const String json = 'json'; + const String textLabel = 'Text'; + final Map expectedfilterSpecification = { + imageLabel: '*.$jpg;*.$png', + textLabel: '*.$txt;*.$json', + }; + final List typeGroups = [ + const XTypeGroup(extensions: [jpg, png], label: imageLabel), + const XTypeGroup(extensions: [txt, json], label: textLabel), + ]; + when(mockFileDialogController.setFileTypes(any, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + int index = 0; + for (final String key in expectedfilterSpecification.keys) { + if (pointer[index].pszName.toDartString() != key || + pointer[index].pszSpec.toDartString() != + expectedfilterSpecification[key]) { + return E_FAIL; + } + index++; + } + return S_OK; + }); + + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test( + 'setFileTypeFilters should call setFileTypes with specific extensions and No label', + () { + const String jpg = 'jpg'; + const String png = 'png'; + const String txt = 'txt'; + const String json = 'json'; + final Map expectedfilterSpecification = { + '*.$jpg;*.$png': '*.$jpg;*.$png', + '*.$txt;*.$json': '*.$txt;*.$json', + }; + final List typeGroups = [ + const XTypeGroup(extensions: [jpg, png]), + const XTypeGroup(extensions: [txt, json]), + ]; + when(mockFileDialogController.setFileTypes(any, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + int index = 0; + for (final String key in expectedfilterSpecification.keys) { + if (pointer[index].pszName.toDartString() != key || + pointer[index].pszSpec.toDartString() != + expectedfilterSpecification[key]) { + return E_FAIL; + } + index++; + } + return S_OK; + }); + + dialogWrapper.setFileTypeFilters(typeGroups); + verify(mockFileDialogController.setFileTypes(typeGroups.length, any)) + .called(1); + expect(dialogWrapper.lastResult, S_OK); + }); + + test('setFolder should not call dialog setFolder if the path is empty', () { + const String emptyPath = ''; + dialogWrapper.setFolder(emptyPath); + verifyNever(mockFileDialogController.setFolder(any)); + }); + + test('setFolder should call dialog setFolder with the provided path', () { + const String path = 'path/to/my/folder'; + when(mockShellWin32Api.createItemFromParsingName(path, any, any)) + .thenReturn(S_OK); + dialogWrapper.setFolder(path); + verify(mockFileDialogController.setFolder(any)).called(1); + }); + + test('setFolder should not call dialog setFolder if createItem fails', () { + const String path = 'path/to/my/folder'; + when(mockShellWin32Api.createItemFromParsingName(path, any, any)) + .thenReturn(E_FAIL); + dialogWrapper.setFolder(path); + verifyNever(mockFileDialogController.setFolder(any)); + }); + + test( + '[DialogMode == Open] show should return null if parent window is not available', + () { + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(E_FAIL); + + final List? result = dialogWrapper.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verifyNever(mockFileDialogController.getResults(any)); + }); + + test( + "[DialogMode == Open] show should return null if can't get results from dialog", + () { + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResults(any)).thenReturn(E_FAIL); + + final List? result = dialogWrapper.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verify(mockFileDialogController.getResults(any)).called(1); + }); + + test( + "[DialogMode == Save] show should return null if can't get result from dialog", + () { + final DialogWrapper dialogWrapperModeSave = + DialogWrapper.withFakeDependencies( + mockFileDialogController, DialogMode.Save, mockShellWin32Api); + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResult(any)).thenReturn(E_FAIL); + + final List? result = dialogWrapperModeSave.show(parentWindow); + + expect(result, null); + verify(mockFileDialogController.show(parentWindow)).called(1); + verify(mockFileDialogController.getResult(any)).called(1); + }); + + test('[DialogMode == Save] show should the selected directory for', () { + const String filePath = 'path/to/file.txt'; + final DialogWrapper dialogWrapperModeSave = + DialogWrapper.withFakeDependencies( + mockFileDialogController, DialogMode.Save, mockShellWin32Api); + const int parentWindow = 0; + when(mockFileDialogController.show(parentWindow)).thenReturn(S_OK); + when(mockFileDialogController.getResult(any)).thenReturn(S_OK); + when(mockShellWin32Api.getPathForShellItem(any)).thenReturn(filePath); + + final List? result = dialogWrapperModeSave.show(parentWindow); + + expect(result?.first, filePath); + }); +} + +void mockSetFileTypesConditions( + MockFileDialogController mockFileDialogController, + String expectedPszName, + String expectedPszSpec) { + when(mockFileDialogController.setFileTypes(1, any)) + .thenAnswer((Invocation realInvocation) { + final Pointer pointer = + realInvocation.positionalArguments[1] as Pointer; + + return pointer[0].pszName.toDartString() == expectedPszName && + pointer[0].pszSpec.toDartString() == expectedPszSpec + ? S_OK + : E_FAIL; + }); +} + +void setDefaultMocks( + MockFileDialogController mockFileDialogController, int defaultReturnValue) { + when(mockFileDialogController.setOptions(any)).thenReturn(defaultReturnValue); + when(mockFileDialogController.getOptions(any)).thenReturn(defaultReturnValue); + when(mockFileDialogController.setOkButtonLabel(any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFileName(any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFileTypes(any, any)) + .thenReturn(defaultReturnValue); + when(mockFileDialogController.setFolder(any)).thenReturn(defaultReturnValue); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart new file mode 100644 index 000000000000..5227cc12b188 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/dialog_wrapper_test.mocks.dart @@ -0,0 +1,151 @@ +// Mocks generated by Mockito 5.3.2 from annotations +// in file_selector_windows/test/file_selector_dart/dialog_wrapper_test.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:ffi' as _i3; + +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart' + as _i2; +import 'package:file_selector_windows/src/file_selector_dart/shell_win32_api.dart' + as _i5; +import 'package:mockito/mockito.dart' as _i1; +import 'package:win32/win32.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [FileDialogController]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockFileDialogController extends _i1.Mock + implements _i2.FileDialogController { + MockFileDialogController() { + _i1.throwOnMissingStub(this); + } + + @override + int setFolder(_i3.Pointer<_i4.COMObject>? path) => (super.noSuchMethod( + Invocation.method( + #setFolder, + [path], + ), + returnValue: 0, + ) as int); + @override + int setFileName(String? name) => (super.noSuchMethod( + Invocation.method( + #setFileName, + [name], + ), + returnValue: 0, + ) as int); + @override + int setFileTypes( + int? count, + _i3.Pointer<_i4.COMDLG_FILTERSPEC>? filters, + ) => + (super.noSuchMethod( + Invocation.method( + #setFileTypes, + [ + count, + filters, + ], + ), + returnValue: 0, + ) as int); + @override + int setOkButtonLabel(String? text) => (super.noSuchMethod( + Invocation.method( + #setOkButtonLabel, + [text], + ), + returnValue: 0, + ) as int); + @override + int getOptions(_i3.Pointer<_i3.Uint32>? outOptions) => (super.noSuchMethod( + Invocation.method( + #getOptions, + [outOptions], + ), + returnValue: 0, + ) as int); + @override + int setOptions(int? options) => (super.noSuchMethod( + Invocation.method( + #setOptions, + [options], + ), + returnValue: 0, + ) as int); + @override + int show(int? parent) => (super.noSuchMethod( + Invocation.method( + #show, + [parent], + ), + returnValue: 0, + ) as int); + @override + int getResult(_i3.Pointer<_i3.Pointer<_i4.COMObject>>? outItem) => + (super.noSuchMethod( + Invocation.method( + #getResult, + [outItem], + ), + returnValue: 0, + ) as int); + @override + int getResults(_i3.Pointer<_i3.Pointer<_i4.COMObject>>? outItems) => + (super.noSuchMethod( + Invocation.method( + #getResults, + [outItems], + ), + returnValue: 0, + ) as int); +} + +/// A class which mocks [ShellWin32Api]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockShellWin32Api extends _i1.Mock implements _i5.ShellWin32Api { + MockShellWin32Api() { + _i1.throwOnMissingStub(this); + } + + @override + int createItemFromParsingName( + String? initialDirectory, + _i3.Pointer<_i4.GUID>? ptrGuid, + _i3.Pointer<_i3.Pointer<_i3.NativeType>>? ptrPath, + ) => + (super.noSuchMethod( + Invocation.method( + #createItemFromParsingName, + [ + initialDirectory, + ptrGuid, + ptrPath, + ], + ), + returnValue: 0, + ) as int); + @override + String getPathForShellItem(_i4.IShellItem? shellItem) => (super.noSuchMethod( + Invocation.method( + #getPathForShellItem, + [shellItem], + ), + returnValue: '', + ) as String); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart new file mode 100644 index 000000000000..84a6713868a6 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_file_dialog.dart @@ -0,0 +1,112 @@ +// Copyright 2013 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:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +// Fake IFileDialog class for testing purposes. +class FakeIFileDialog extends Fake implements IFileDialog { + int _getOptionsCalledTimes = 0; + int _getResultCalledTimes = 0; + int _setOptionsCalledTimes = 0; + int _setFolderCalledTimes = 0; + int _setFileNameCalledTimes = 0; + int _setFileTypesCalledTimes = 0; + int _setOkButtonLabelCalledTimes = 0; + int _showCalledTimes = 0; + + @override + int getOptions(Pointer pfos) { + _getOptionsCalledTimes++; + return S_OK; + } + + @override + int setOptions(int options) { + _setOptionsCalledTimes++; + return S_OK; + } + + @override + int getResult(Pointer> ppsi) { + _getResultCalledTimes++; + return S_OK; + } + + @override + int setFolder(Pointer psi) { + _setFolderCalledTimes++; + return S_OK; + } + + @override + int setFileTypes(int cFileTypes, Pointer rgFilterSpec) { + _setFileTypesCalledTimes++; + return S_OK; + } + + @override + int setFileName(Pointer pszName) { + _setFileNameCalledTimes++; + return S_OK; + } + + @override + int setOkButtonLabel(Pointer pszText) { + _setOkButtonLabelCalledTimes++; + return S_OK; + } + + @override + int show(int hwndOwner) { + _showCalledTimes++; + return S_OK; + } + + void resetCounters() { + _getOptionsCalledTimes = 0; + _getResultCalledTimes = 0; + _setOptionsCalledTimes = 0; + _setFolderCalledTimes = 0; + _setFileTypesCalledTimes = 0; + _setOkButtonLabelCalledTimes = 0; + _showCalledTimes = 0; + _setFileNameCalledTimes = 0; + } + + int getOptionsCalledTimes() { + return _getOptionsCalledTimes; + } + + int setOptionsCalledTimes() { + return _setOptionsCalledTimes; + } + + int getResultCalledTimes() { + return _getResultCalledTimes; + } + + int setFolderCalledTimes() { + return _setFolderCalledTimes; + } + + int setFileNameCalledTimes() { + return _setFileNameCalledTimes; + } + + int setFileTypesCalledTimes() { + return _setFileTypesCalledTimes; + } + + int setOkButtonLabelCalledTimes() { + return _setOkButtonLabelCalledTimes; + } + + int showCalledTimes() { + return _showCalledTimes; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart new file mode 100644 index 000000000000..14dd9eb9f215 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog.dart @@ -0,0 +1,55 @@ +// Copyright 2013 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:ffi'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +// Fake IFileOpenDialog class for testing purposes. +class FakeIFileOpenDialog extends Fake implements IFileOpenDialog { + int _getResultsCalledTimes = 0; + int _getReleaseCalledTimes = 0; + bool _shouldFail = false; + + @override + Pointer get ptr => nullptr; + + @override + int release() { + _getReleaseCalledTimes++; + return S_OK; + } + + @override + int getResults(Pointer> ppsi) { + _getResultsCalledTimes++; + if (_shouldFail) { + throw WindowsException(E_FAIL); + } + + return S_OK; + } + + void resetCounters() { + _getResultsCalledTimes = 0; + _getReleaseCalledTimes = 0; + } + + int getResultsCalledTimes() { + return _getResultsCalledTimes; + } + + int getReleaseCalledTimes() { + return _getReleaseCalledTimes; + } + + void mockFailure() { + _shouldFail = true; + } + + void mockSuccess() { + _shouldFail = false; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart new file mode 100644 index 000000000000..042a14f56d5a --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/fake_ifile_open_dialog_factory.dart @@ -0,0 +1,40 @@ +// Copyright 2013 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_windows/src/file_selector_dart/ifile_open_dialog_factory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_ifile_open_dialog.dart'; + +// Fake FakeIFileOpenDialogFactory class for testing purposes. +class FakeIFileOpenDialogFactory extends Fake + implements IFileOpenDialogFactory { + int _fromCalledTimes = 0; + bool _shouldFail = false; + + final FakeIFileOpenDialog fakeIFileOpenDialog = FakeIFileOpenDialog(); + + @override + IFileOpenDialog from(IFileDialog dialog) { + _fromCalledTimes++; + if (_shouldFail) { + throw WindowsException(E_NOINTERFACE); + } + + return fakeIFileOpenDialog; + } + + int getFromCalledTimes() { + return _fromCalledTimes; + } + + void mockSuccess() { + _shouldFail = false; + } + + void mockFailure() { + _shouldFail = true; + } +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart new file mode 100644 index 000000000000..fe100ecdebca --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_factory_test.dart @@ -0,0 +1,21 @@ +// Copyright 2013 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_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller_factory.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_file_dialog.dart'; + +void main() { + final FileDialogControllerFactory fileDialogControllerFactory = + FileDialogControllerFactory(); + final IFileDialog dialog = FakeIFileDialog(); + + test('createController should return a FileDialogController', () { + expect(fileDialogControllerFactory.createController(dialog), + isA()); + }); +} diff --git a/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart new file mode 100644 index 000000000000..719a9ad74277 --- /dev/null +++ b/packages/file_selector/file_selector_windows/test/file_selector_dart/file_dialog_controller_test.dart @@ -0,0 +1,131 @@ +// Copyright 2013 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:ffi'; + +import 'package:ffi/ffi.dart'; +import 'package:file_selector_windows/src/file_selector_dart/file_dialog_controller.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:win32/win32.dart'; + +import 'fake_file_dialog.dart'; +import 'fake_ifile_open_dialog_factory.dart'; + +void main() { + final FakeIFileDialog fakeFileOpenDialog = FakeIFileDialog(); + final FakeIFileOpenDialogFactory fakeIFileOpenDialogFactory = + FakeIFileOpenDialogFactory(); + final FileDialogController fileDialogController = + FileDialogController(fakeFileOpenDialog, fakeIFileOpenDialogFactory); + + setUp(() { + fakeIFileOpenDialogFactory.mockSuccess(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.mockSuccess(); + }); + + tearDown(() { + fakeFileOpenDialog.resetCounters(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.resetCounters(); + }); + + test('setFolder should call dialog setFolder', () { + final Pointer ptrFolder = calloc(); + fileDialogController.setFolder(ptrFolder); + free(ptrFolder); + expect(fakeFileOpenDialog.setFolderCalledTimes(), 1); + }); + + test('setFileName should call dialog setFileName', () { + fileDialogController.setFileName('fileName'); + expect(fakeFileOpenDialog.setFileNameCalledTimes(), 1); + }); + + test('setFileTypes should call dialog setFileTypes', () { + final Pointer ptrFilters = calloc(); + fileDialogController.setFileTypes(1, ptrFilters); + free(ptrFilters); + expect(fakeFileOpenDialog.setFileTypesCalledTimes(), 1); + }); + + test('setOkButtonLabel should call dialog setOkButtonLabel', () { + fileDialogController.setOkButtonLabel('button'); + expect(fakeFileOpenDialog.setOkButtonLabelCalledTimes(), 1); + }); + + test('show should call dialog show', () { + fileDialogController.show(0); + expect(fakeFileOpenDialog.showCalledTimes(), 1); + }); + + test('getOptions should call dialog getOptions', () { + final Pointer ptrOptions = calloc(); + fileDialogController.getOptions(ptrOptions); + free(ptrOptions); + expect(fakeFileOpenDialog.getOptionsCalledTimes(), 1); + }); + + test('setOptions should call dialog setOptions', () { + fileDialogController.setOptions(32); + expect(fakeFileOpenDialog.setOptionsCalledTimes(), 1); + }); + + test('getResult should call dialog getResult', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResult(ptrCOMObject); + free(ptrCOMObject); + expect(fakeFileOpenDialog.getResultCalledTimes(), 1); + }); + + test('getResults should call the from method of the factory', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect(fakeIFileOpenDialogFactory.getFromCalledTimes(), 1); + }); + + test('getResults should call dialog getResults', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getResultsCalledTimes(), + 1); + }); + + test( + 'getResults should return an error when building a file open dialog throws', + () { + final Pointer> ptrCOMObject = + calloc>(); + fakeIFileOpenDialogFactory.mockFailure(); + free(ptrCOMObject); + expect(fileDialogController.getResults(ptrCOMObject), E_FAIL); + }); + + test( + 'getResults should return an error and release the dialog when getting results throws', + () { + final Pointer> ptrCOMObject = + calloc>(); + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.mockFailure(); + free(ptrCOMObject); + expect(fileDialogController.getResults(ptrCOMObject), E_FAIL); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getReleaseCalledTimes(), + 1); + }); + + test('getResults should call dialog release', () { + final Pointer> ptrCOMObject = + calloc>(); + fileDialogController.getResults(ptrCOMObject); + free(ptrCOMObject); + expect( + fakeIFileOpenDialogFactory.fakeIFileOpenDialog.getReleaseCalledTimes(), + 1); + }); +}