From 6843278596458ce7f1d99cd630ee03de74e84f3f Mon Sep 17 00:00:00 2001 From: Ellet Date: Wed, 13 Nov 2024 17:22:06 +0300 Subject: [PATCH] feat(macos): (WIP) adds support for macOS PHPicker for image_picker_macos - Updates the `image_picke_macos`'s `pubspec.yaml` to add `pigeon` as dev dependency and add the `pluginClass` for Swift native code - Adds the `ImagePickerPlugin` in `image_picker_macos/macos` for native macOS plugin with support for SPM and CocoaPods with basic native unit tests - Uses the steps in https://github.com/flutter/flutter/blob/master/docs/ecosystem/testing/Plugin-Tests.md#enabling-xctests-or-xcuitests to enable `XCTests` and `XCUITests` - Updates the `image_picker_macos_test.dart` to fix the test failure and ensure PHPicker is disabled by default - Adds a new button in the example to enable/disable PHPicker macOS implementation and enable the PHPicker by default - Updates the `README.md` of `image_picker_macos` and `image_picker` to document the usage - Removes two TODOs in `image_picker_macos.dart` as they are done with this PR - Adds TODOs that need to be done before merging the PR, some of them are questions, will be removed - Implement the getMultiImageWithOptions() since the getMultiImage is deprecated, updates getMultiImage() to delegate to getMultiImageWithOptions() since getMultiImageWithOptions() is required to access the limit property - Updates the Dart unit tests of image_picker_macos - Adds simple integration test for the example - Updates `pubspec.yaml` and `CHANGELOG.md` of `image_picker` and `image_picker_macos` --- .../image_picker/image_picker/CHANGELOG.md | 4 + packages/image_picker/image_picker/README.md | 13 +- .../image_picker/image_picker/pubspec.yaml | 2 +- .../image_picker_macos/CHANGELOG.md | 3 +- .../image_picker/image_picker_macos/README.md | 42 +- .../integration_test/image_picker_test.dart | 62 ++ .../image_picker_macos/example/lib/main.dart | 51 ++ .../macos/Runner.xcodeproj/project.pbxproj | 343 ++++++++- .../xcshareddata/xcschemes/Runner.xcscheme | 40 ++ .../example/macos/Runner/AppDelegate.swift | 4 + .../RunnerTests/ImageCompressTests.swift | 37 + .../macos/RunnerTests/ImageResizeTests.swift | 236 +++++++ .../macos/RunnerTests/RunnerTests.swift | 94 +++ .../macos/RunnerUITests/RunnerUITests.swift | 32 + .../image_picker_macos/example/pubspec.yaml | 2 + .../example/test_driver/integration_test.dart | 7 + .../lib/image_picker_macos.dart | 134 +++- .../lib/src/messages.g.dart | 297 ++++++++ .../macos/image_picker_macos.podspec | 23 + .../macos/image_picker_macos/Package.swift | 28 + .../image_picker_macos/ImageCompress.swift | 71 ++ .../image_picker_macos/ImagePickerImpl.swift | 519 ++++++++++++++ .../ImagePickerPlugin.swift | 14 + .../image_picker_macos/ImageResize.swift | 94 +++ .../image_picker_macos/Messages.g.swift | 309 ++++++++ .../Resources/PrivacyInfo.xcprivacy | 12 + .../image_picker_macos/pigeons/copyright.txt | 3 + .../image_picker_macos/pigeons/messages.dart | 85 +++ .../image_picker_macos/pubspec.yaml | 4 +- .../test/image_picker_macos_test.dart | 668 +++++++++++++++++- .../test/image_picker_macos_test.mocks.dart | 64 ++ .../image_picker_macos/test/test_api.g.dart | 174 +++++ 32 files changed, 3415 insertions(+), 56 deletions(-) create mode 100644 packages/image_picker/image_picker_macos/example/integration_test/image_picker_test.dart create mode 100644 packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageCompressTests.swift create mode 100644 packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageResizeTests.swift create mode 100644 packages/image_picker/image_picker_macos/example/macos/RunnerTests/RunnerTests.swift create mode 100644 packages/image_picker/image_picker_macos/example/macos/RunnerUITests/RunnerUITests.swift create mode 100644 packages/image_picker/image_picker_macos/example/test_driver/integration_test.dart create mode 100644 packages/image_picker/image_picker_macos/lib/src/messages.g.dart create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos.podspec create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Package.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageCompress.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerImpl.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerPlugin.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageResize.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Messages.g.swift create mode 100644 packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Resources/PrivacyInfo.xcprivacy create mode 100644 packages/image_picker/image_picker_macos/pigeons/copyright.txt create mode 100644 packages/image_picker/image_picker_macos/pigeons/messages.dart create mode 100644 packages/image_picker/image_picker_macos/test/test_api.g.dart diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index 29fb408f8b45..044b78e3d5f0 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,3 +1,7 @@ +## 1.1.3 + +* Updates README to include a reference to the macOS PHPicker feature. + ## 1.1.2 * Adds comment for the limit parameter. diff --git a/packages/image_picker/image_picker/README.md b/packages/image_picker/image_picker/README.md index 866adf58118e..ef91fa400236 100755 --- a/packages/image_picker/image_picker/README.md +++ b/packages/image_picker/image_picker/README.md @@ -153,7 +153,7 @@ encourage the community to build packages that implement #### macOS installation -Since the macOS implementation uses `file_selector`, you will need to +Since the macOS implementation uses `file_selector` by default, you will need to add a filesystem access [entitlement](https://flutter.dev/to/macos-entitlements): @@ -162,6 +162,17 @@ add a filesystem access ``` +This setup is still required when using the [macOS PHPicker](#macos-phpicker) on **macOS 12 and older versions**, since it's only supported on **macOS 13+** and will fallback to the `file_selector` implementation. + +#### macOS PHPicker + +To use the [macOS native image picker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) which is supported on **macOS 13 and newer versions**, +refer to the [image_picker_macos PHPicker](https://pub.dev/packages/image_picker_macos#phpicker) section. + +* **on macOS 13 and newer versions**: If this feature is used, the +filesystem access entitlement in the [macOS installation](#macos-installation) is not required. +* **on macOS 12 and older versions**: This feature is unsupported and will fallback to `file_selector` implementation, the filesystem access entitlement in the [macOS installation](#macos-installation) is required. + ### Example diff --git a/packages/image_picker/image_picker/pubspec.yaml b/packages/image_picker/image_picker/pubspec.yaml index 6c5676a60e0d..ad021b4c058c 100755 --- a/packages/image_picker/image_picker/pubspec.yaml +++ b/packages/image_picker/image_picker/pubspec.yaml @@ -3,7 +3,7 @@ description: Flutter plugin for selecting images from the Android and iOS image library, and taking new pictures with the camera. repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 1.1.2 +version: 1.1.3 environment: sdk: ^3.3.0 diff --git a/packages/image_picker/image_picker_macos/CHANGELOG.md b/packages/image_picker/image_picker_macos/CHANGELOG.md index 6012fff8dbcd..7a3e305eb1fa 100644 --- a/packages/image_picker/image_picker_macos/CHANGELOG.md +++ b/packages/image_picker/image_picker_macos/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 0.3.0 * Updates minimum supported SDK version to Flutter 3.19/Dart 3.3. +* Adds macOS 13+ PHPicker functionality (optional and disabled by default). ## 0.2.1+1 diff --git a/packages/image_picker/image_picker_macos/README.md b/packages/image_picker/image_picker_macos/README.md index 9aa87453532e..d9da5ce328c0 100644 --- a/packages/image_picker/image_picker_macos/README.md +++ b/packages/image_picker/image_picker_macos/README.md @@ -2,15 +2,45 @@ A macOS implementation of [`image_picker`][1]. +## PHPicker + +macOS 13.0 and newer versions supports native image picking via [PHPickerViewController][5]. + +To use this feature, add the following code to your app before calling any `image_picker` APIs: + + +```dart +import 'package:image_picker_macos/image_picker_macos.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +// ··· + final ImagePickerPlatform imagePickerImplementation = + ImagePickerPlatform.instance; + if (imagePickerImplementation is ImagePickerMacOS) { + imagePickerImplementation.useMacOSPHPicker = true; + } +``` + +This implementation depends on the photos in the [Photos for macOS App][6], +if the user didn't open the app or import any photos to the app, +they will see: `No photos` or `No Photos or Videos` message even if they +have them as files on their desktop. The macOS Photos app supports importing images from an iOS device. + +> [!NOTE] +> This feature is only supported on **macOS 13.0 and newer versions**, on older versions it will fallback to using [file_selector][3] if enabled. +> By defaults it's disabled on all versions. + ## Limitations `ImageSource.camera` is not supported unless a `cameraDelegate` is set. ### pickImage() -The arguments `maxWidth`, `maxHeight`, and `imageQuality` are not currently supported. + +The arguments `maxWidth`, `maxHeight`, `imageQuality` and `limit` are only supported when using the [PHPicker](#phpicker) implementation; they are not available in the default [file_selector][5] implementation. + +The argument `requestFullMetadata` is unsupported on macOS. ### pickVideo() -The argument `maxDuration` is not currently supported. +The argument `maxDuration` is not supported even when using the [PHPicker](#phpicker) implementation. ## Usage @@ -25,14 +55,18 @@ should add it to your `pubspec.yaml` as usual. ### Entitlements -This package is currently implemented using [`file_selector`][3], so you will -need to add a read-only file acces [entitlement][4]: +This package’s default implementation relies on [file_selector][3], +which requires the following read-only file access entitlement: ```xml com.apple.security.files.user-selected.read-only ``` +If you're using the [PHPicker](#phpicker) and require at **least macOS 13** to run the app, this entitlement is not required. + [1]: https://pub.dev/packages/image_picker [2]: https://flutter.dev/to/endorsed-federated-plugin [3]: https://pub.dev/packages/file_selector [4]: https://flutter.dev/to/macos-entitlements +[5]: https://developer.apple.com/documentation/photokit/phpickerviewcontroller +[6]: https://www.apple.com/in/macos/photos/ diff --git a/packages/image_picker/image_picker_macos/example/integration_test/image_picker_test.dart b/packages/image_picker/image_picker_macos/example/integration_test/image_picker_test.dart new file mode 100644 index 000000000000..921d3448aa17 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/integration_test/image_picker_test.dart @@ -0,0 +1,62 @@ +// 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:example/main.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:image_picker_macos/image_picker_macos.dart'; +import 'package:image_picker_macos/src/messages.g.dart'; +import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'package:integration_test/integration_test.dart'; + +ImagePickerMacOS get requireMacOSImplementation { + final ImagePickerPlatform imagePickerImplementation = + ImagePickerPlatform.instance; + if (imagePickerImplementation is! ImagePickerMacOS) { + fail('Expected the implementation to be $ImagePickerMacOS'); + } + return imagePickerImplementation; +} + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + group('example', () { + testWidgets( + 'Pressing the PHPicker toggle button updates it correctly', + (WidgetTester tester) async { + final ImagePickerMacOS imagePickerImplementation = + requireMacOSImplementation; + expect(imagePickerImplementation.useMacOSPHPicker, false, + reason: 'The default is to not using PHPicker'); + + await tester.pumpWidget(const MyApp()); + final Finder togglePHPickerFinder = + find.byTooltip('toggle macOS PHPPicker'); + expect(togglePHPickerFinder, findsOneWidget); + + await tester.tap(togglePHPickerFinder); + expect(imagePickerImplementation.useMacOSPHPicker, true, + reason: 'Pressing the toggle button should update it correctly'); + + await tester.tap(togglePHPickerFinder); + expect(imagePickerImplementation.useMacOSPHPicker, false, + reason: 'Pressing the toggle button should update it correctly'); + }, + ); + testWidgets( + 'multi-video selection is not implemented', + (WidgetTester tester) async { + final ImagePickerApi hostApi = ImagePickerApi(); + await expectLater( + hostApi.pickVideos(GeneralOptions(limit: 2)), + throwsA(predicate( + (PlatformException e) => + e.code == 'UNIMPLEMENTED' && + e.message == 'Multi-video selection is not implemented', + )), + ); + }, + ); + }); +} diff --git a/packages/image_picker/image_picker_macos/example/lib/main.dart b/packages/image_picker/image_picker_macos/example/lib/main.dart index 8f4887095c13..76da5f6e7f4d 100644 --- a/packages/image_picker/image_picker_macos/example/lib/main.dart +++ b/packages/image_picker/image_picker_macos/example/lib/main.dart @@ -8,11 +8,22 @@ import 'dart:async'; import 'dart:io'; import 'package:flutter/material.dart'; +// #docregion phpicker-example +import 'package:image_picker_macos/image_picker_macos.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +// #enddocregion phpicker-example import 'package:mime/mime.dart'; import 'package:video_player/video_player.dart'; void main() { + // Set to use macOS PHPicker. + // #docregion phpicker-example + final ImagePickerPlatform imagePickerImplementation = + ImagePickerPlatform.instance; + if (imagePickerImplementation is ImagePickerMacOS) { + imagePickerImplementation.useMacOSPHPicker = true; + } + // #enddocregion phpicker-example runApp(const MyApp()); } @@ -385,6 +396,46 @@ class _MyHomePageState extends State { child: const Icon(Icons.videocam), ), ), + Padding( + padding: const EdgeInsets.only(top: 16.0), + child: FloatingActionButton( + onPressed: () { + void showSnackbarText(String text) { + ScaffoldMessenger.of(context) + ..clearSnackBars() + ..showSnackBar( + SnackBar(content: Text(text)), + ); + } + + if (_picker is! ImagePickerMacOS) { + throw StateError( + 'Expected the implementation to be $ImagePickerMacOS but was ${_picker.runtimeType}'); + } + + if (_picker.useMacOSPHPicker) { + _picker.useMacOSPHPicker = false; + setState(() {}); + showSnackbarText('Switched to file_picker implementation.'); + } else { + _picker.useMacOSPHPicker = true; + setState(() {}); + showSnackbarText( + 'Switched to macOS PHPPicker implementation.'); + } + }, + tooltip: 'toggle macOS PHPPicker', + child: () { + if (_picker is ImagePickerMacOS) { + return _picker.useMacOSPHPicker + ? const Icon(Icons.apple) + : const Icon(Icons.file_open); + } + throw StateError( + 'Expected the implementation to be $ImagePickerMacOS but was ${_picker.runtimeType}'); + }(), + ), + ), ], ), ); diff --git a/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/project.pbxproj b/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/project.pbxproj index 11508d6d349a..cd915eaea3a9 100644 --- a/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/project.pbxproj +++ b/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/project.pbxproj @@ -27,6 +27,11 @@ 33CC10F62044A3C60003C045 /* MainMenu.xib in Resources */ = {isa = PBXBuildFile; fileRef = 33CC10F42044A3C60003C045 /* MainMenu.xib */; }; 33CC11132044BFA00003C045 /* MainFlutterWindow.swift in Sources */ = {isa = PBXBuildFile; fileRef = 33CC11122044BFA00003C045 /* MainFlutterWindow.swift */; }; 38BD9D1FDAF3360EC1CC0018 /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 2E9F2DE12CD9DE067306B460 /* Pods_Runner.framework */; }; + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 7ABC95832CBD9D810004CBA6 /* ImageCompressTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC957F2CBD9D810004CBA6 /* ImageCompressTests.swift */; }; + 7ABC95842CBD9D810004CBA6 /* ImageResizeTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC95802CBD9D810004CBA6 /* ImageResizeTests.swift */; }; + 7ABC95852CBD9D810004CBA6 /* RunnerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC95812CBD9D810004CBA6 /* RunnerTests.swift */; }; + 7ABC95892CBD9D8A0004CBA6 /* RunnerUITests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7ABC95862CBD9D8A0004CBA6 /* RunnerUITests.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -37,6 +42,20 @@ remoteGlobalIDString = 33CC111A2044C6BA0003C045; remoteInfo = FLX; }; + 7ABC952F2CB979800004CBA6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; + 7ABC954F2CBAF9680004CBA6 /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 33CC10E52044A3C60003C045 /* Project object */; + proxyType = 1; + remoteGlobalIDString = 33CC10EC2044A3C60003C045; + remoteInfo = Runner; + }; /* End PBXContainerItemProxy section */ /* Begin PBXCopyFilesBuildPhase section */ @@ -70,6 +89,12 @@ 33E51914231749380026EE4D /* Release.entitlements */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.entitlements; path = Release.entitlements; sourceTree = ""; }; 33E5194F232828860026EE4D /* AppInfo.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = AppInfo.xcconfig; sourceTree = ""; }; 6D83DCCDFE2A45D91B8A2673 /* Pods-Runner.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.debug.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.debug.xcconfig"; sourceTree = ""; }; + 7ABC952B2CB979800004CBA6 /* RunnerTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7ABC95492CBAF9680004CBA6 /* RunnerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RunnerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 7ABC957F2CBD9D810004CBA6 /* ImageCompressTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageCompressTests.swift; sourceTree = ""; }; + 7ABC95802CBD9D810004CBA6 /* ImageResizeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageResizeTests.swift; sourceTree = ""; }; + 7ABC95812CBD9D810004CBA6 /* RunnerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerTests.swift; sourceTree = ""; }; + 7ABC95862CBD9D8A0004CBA6 /* RunnerUITests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RunnerUITests.swift; sourceTree = ""; }; 7AFA3C8E1D35360C0083082E /* Release.xcconfig */ = {isa = PBXFileReference; lastKnownFileType = text.xcconfig; path = Release.xcconfig; sourceTree = ""; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; CD373636C90085FB713CE436 /* Pods-Runner.profile.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Runner.profile.xcconfig"; path = "Target Support Files/Pods-Runner/Pods-Runner.profile.xcconfig"; sourceTree = ""; }; @@ -80,10 +105,25 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */, 38BD9D1FDAF3360EC1CC0018 /* Pods_Runner.framework in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; + 7ABC95282CB979800004CBA6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7ABC95462CBAF9680004CBA6 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ @@ -94,7 +134,6 @@ 103E92CF1EBAAB82E57C06F1 /* Pods-Runner.release.xcconfig */, CD373636C90085FB713CE436 /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -114,6 +153,8 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 7ABC95822CBD9D810004CBA6 /* RunnerTests */, + 7ABC95882CBD9D8A0004CBA6 /* RunnerUITests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, 0C0105042ACC016BCC44F609 /* Pods */, @@ -124,6 +165,8 @@ isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* example.app */, + 7ABC952B2CB979800004CBA6 /* RunnerTests.xctest */, + 7ABC95492CBAF9680004CBA6 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -163,6 +206,24 @@ path = Runner; sourceTree = ""; }; + 7ABC95822CBD9D810004CBA6 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 7ABC957F2CBD9D810004CBA6 /* ImageCompressTests.swift */, + 7ABC95802CBD9D810004CBA6 /* ImageResizeTests.swift */, + 7ABC95812CBD9D810004CBA6 /* RunnerTests.swift */, + ); + path = RunnerTests; + sourceTree = ""; + }; + 7ABC95882CBD9D8A0004CBA6 /* RunnerUITests */ = { + isa = PBXGroup; + children = ( + 7ABC95862CBD9D8A0004CBA6 /* RunnerUITests.swift */, + ); + path = RunnerUITests; + sourceTree = ""; + }; D73912EC22F37F3D000D13A0 /* Frameworks */ = { isa = PBXGroup; children = ( @@ -184,7 +245,6 @@ 33CC10EB2044A3C60003C045 /* Resources */, 33CC110E2044A8840003C045 /* Bundle Framework */, 3399D490228B24CF009A79C7 /* ShellScript */, - 47A625D09D635034DC1B5C5E /* [CP] Embed Pods Frameworks */, ); buildRules = ( ); @@ -192,17 +252,56 @@ 33CC11202044C79F0003C045 /* PBXTargetDependency */, ); name = Runner; + packageProductDependencies = ( + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */, + ); productName = Runner; productReference = 33CC10ED2044A3C60003C045 /* example.app */; productType = "com.apple.product-type.application"; }; + 7ABC952A2CB979800004CBA6 /* RunnerTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7ABC95342CB979800004CBA6 /* Build configuration list for PBXNativeTarget "RunnerTests" */; + buildPhases = ( + 7ABC95272CB979800004CBA6 /* Sources */, + 7ABC95282CB979800004CBA6 /* Frameworks */, + 7ABC95292CB979800004CBA6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7ABC95302CB979800004CBA6 /* PBXTargetDependency */, + ); + name = RunnerTests; + productName = RunnerTests; + productReference = 7ABC952B2CB979800004CBA6 /* RunnerTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; + 7ABC95482CBAF9680004CBA6 /* RunnerUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7ABC95542CBAF9680004CBA6 /* Build configuration list for PBXNativeTarget "RunnerUITests" */; + buildPhases = ( + 7ABC95452CBAF9680004CBA6 /* Sources */, + 7ABC95462CBAF9680004CBA6 /* Frameworks */, + 7ABC95472CBAF9680004CBA6 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 7ABC95502CBAF9680004CBA6 /* PBXTargetDependency */, + ); + name = RunnerUITests; + productName = RunnerUITests; + productReference = 7ABC95492CBAF9680004CBA6 /* RunnerUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ 33CC10E52044A3C60003C045 /* Project object */ = { isa = PBXProject; attributes = { - LastSwiftUpdateCheck = 0920; + LastSwiftUpdateCheck = 1600; LastUpgradeCheck = 1510; ORGANIZATIONNAME = ""; TargetAttributes = { @@ -220,6 +319,15 @@ CreatedOnToolsVersion = 9.2; ProvisioningStyle = Manual; }; + 7ABC952A2CB979800004CBA6 = { + CreatedOnToolsVersion = 16.0; + LastSwiftMigration = 1600; + TestTargetID = 33CC10EC2044A3C60003C045; + }; + 7ABC95482CBAF9680004CBA6 = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 33CC10EC2044A3C60003C045; + }; }; }; buildConfigurationList = 33CC10E82044A3C60003C045 /* Build configuration list for PBXProject "Runner" */; @@ -231,12 +339,17 @@ Base, ); mainGroup = 33CC10E42044A3C60003C045; + packageReferences = ( + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, + ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 33CC10EC2044A3C60003C045 /* Runner */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + 7ABC952A2CB979800004CBA6 /* RunnerTests */, + 7ABC95482CBAF9680004CBA6 /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -251,6 +364,20 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7ABC95292CB979800004CBA6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7ABC95472CBAF9680004CBA6 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXShellScriptBuildPhase section */ @@ -292,23 +419,6 @@ shellPath = /bin/sh; shellScript = "\"$FLUTTER_ROOT\"/packages/flutter_tools/bin/macos_assemble.sh && touch Flutter/ephemeral/tripwire"; }; - 47A625D09D635034DC1B5C5E /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-Runner/Pods-Runner-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; DFDF70D0C2243DC8DB792B23 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; @@ -344,6 +454,24 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7ABC95272CB979800004CBA6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7ABC95832CBD9D810004CBA6 /* ImageCompressTests.swift in Sources */, + 7ABC95842CBD9D810004CBA6 /* ImageResizeTests.swift in Sources */, + 7ABC95852CBD9D810004CBA6 /* RunnerTests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 7ABC95452CBAF9680004CBA6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7ABC95892CBD9D8A0004CBA6 /* RunnerUITests.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ @@ -352,6 +480,16 @@ target = 33CC111A2044C6BA0003C045 /* Flutter Assemble */; targetProxy = 33CC111F2044C79F0003C045 /* PBXContainerItemProxy */; }; + 7ABC95302CB979800004CBA6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 7ABC952F2CB979800004CBA6 /* PBXContainerItemProxy */; + }; + 7ABC95502CBAF9680004CBA6 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 33CC10EC2044A3C60003C045 /* Runner */; + targetProxy = 7ABC954F2CBAF9680004CBA6 /* PBXContainerItemProxy */; + }; /* End PBXTargetDependency section */ /* Begin PBXVariantGroup section */ @@ -594,6 +732,137 @@ }; name = Release; }; + 7ABC95312CB979800004CBA6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Debug; + }; + 7ABC95322CB979800004CBA6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Release; + }; + 7ABC95332CB979800004CBA6 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + BUNDLE_LOADER = "$(TEST_HOST)"; + CLANG_ENABLE_MODULES = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerTests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/example.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/example"; + }; + name = Profile; + }; + 7ABC95512CBAF9680004CBA6 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + }; + name = Debug; + }; + 7ABC95522CBAF9680004CBA6 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + }; + name = Release; + }; + 7ABC95532CBAF9680004CBA6 /* Profile */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GENERATE_INFOPLIST_FILE = YES; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MACOSX_DEPLOYMENT_TARGET = 10.14; + MARKETING_VERSION = 1.0; + MTL_FAST_MATH = YES; + PRODUCT_BUNDLE_IDENTIFIER = dev.flutter.plugins.RunnerUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_VERSION = 5.0; + TEST_TARGET_NAME = Runner; + }; + name = Profile; + }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ @@ -627,7 +896,41 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; + 7ABC95342CB979800004CBA6 /* Build configuration list for PBXNativeTarget "RunnerTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7ABC95312CB979800004CBA6 /* Debug */, + 7ABC95322CB979800004CBA6 /* Release */, + 7ABC95332CB979800004CBA6 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 7ABC95542CBAF9680004CBA6 /* Build configuration list for PBXNativeTarget "RunnerUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 7ABC95512CBAF9680004CBA6 /* Debug */, + 7ABC95522CBAF9680004CBA6 /* Release */, + 7ABC95532CBAF9680004CBA6 /* Profile */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; /* End XCConfigurationList section */ + +/* Begin XCLocalSwiftPackageReference section */ + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { + isa = XCLocalSwiftPackageReference; + relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; + }; +/* End XCLocalSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */ = { + isa = XCSwiftPackageProductDependency; + productName = FlutterGeneratedPluginSwiftPackage; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 33CC10E52044A3C60003C045 /* Project object */; } diff --git a/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme b/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme index 5b055a3a376e..692345dd1b94 100644 --- a/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme +++ b/packages/image_picker/image_picker_macos/example/macos/Runner.xcodeproj/xcshareddata/xcschemes/Runner.xcscheme @@ -5,6 +5,24 @@ + + + + + + + + + + + + + + + + + + Bool { return true } + + override func applicationSupportsSecureRestorableState(_ app: NSApplication) -> Bool { + return true + } } diff --git a/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageCompressTests.swift b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageCompressTests.swift new file mode 100644 index 000000000000..a3fa3152dfd8 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageCompressTests.swift @@ -0,0 +1,37 @@ +// 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 XCTest + +@testable import image_picker_macos + +final class ImageCompressTests: XCTestCase { + + private func createTestImage(size: NSSize) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + NSColor.white.set() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image + } + + func testShouldCompressImage() { + XCTAssertFalse(shouldCompressImage(quality: 100), "Quality 100 should not compress the image.") + XCTAssertTrue(shouldCompressImage(quality: 80), "Quality bellow 100 should compress the image.") + XCTAssertFalse( + shouldCompressImage(quality: nil), "Should not compress the image when the quality is nil.") + } + + func testImageCompression() throws { + let testImage = createTestImage(size: NSSize(width: 100, height: 100)) + + let compressedImage = try testImage.compressed(quality: 80) + + XCTAssertLessThan( + compressedImage.tiffRepresentation!.count, testImage.tiffRepresentation!.count, + "Compressed image data should be smaller than the original image data.") + } + +} diff --git a/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageResizeTests.swift b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageResizeTests.swift new file mode 100644 index 000000000000..a6a331d82ebc --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/ImageResizeTests.swift @@ -0,0 +1,236 @@ +// 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 XCTest + +@testable import image_picker_macos + +final class ImageResizeTests: XCTestCase { + + private func createTestImage(size: NSSize) -> NSImage { + let image = NSImage(size: size) + image.lockFocus() + NSColor.black.set() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image + } + + func testNilMaxSize() { + let originalImage = createTestImage(size: NSSize(width: 1200, height: 800)) + + let resizedImage = originalImage.resizedOrOriginal(maxSize: nil) + + XCTAssertEqual( + resizedImage, originalImage, "Should return the original image when \(MaxSize.self) is nil.") + } + + func testResizeExceedingMaxSize() { + let originalImage = createTestImage(size: NSSize(width: 1200, height: 800)) + + let maxSize = MaxSize(width: 600, height: 400) + let resizedImage = originalImage.resizedOrOriginal(maxSize: maxSize) + + // The resized image should be scaled down to fit within the max size while maintaining the aspect ratio. + XCTAssertEqual( + resizedImage.size.width, maxSize.width, + "Resized image width should not exceed the maximum allowed width.") + XCTAssertEqual( + resizedImage.size.height, maxSize.height, + "Resized image height should not exceed the maximum allowed height.") + } + + func testResizeBelowMaxSize() { + let originalImage = createTestImage(size: NSSize(width: 600, height: 400)) + + let resizedImage = originalImage.resizedOrOriginal(maxSize: MaxSize(width: 1200, height: 800)) + + // The resized image should remain the same size, as it's already smaller than the max size. + XCTAssertEqual( + resizedImage.size.width, originalImage.size.width, + "Resized image width should remain unchanged when smaller than the maximum allowed width.") + XCTAssertEqual( + resizedImage.size.height, originalImage.size.height, + "Resized image height should remain unchanged when smaller than the maximum allowed height.") + } + + func testResizeWidthOnly() { + // An image where only the width exceeds the max size + let originalImage = createTestImage(size: NSSize(width: 600, height: 200)) + + let maxSize = MaxSize(width: 300, height: 400) + let resizedImage = originalImage.resizedOrOriginal(maxSize: maxSize) + + // The image should be resized proportionally based on width + XCTAssertEqual( + resizedImage.size.width, maxSize.width, "The width should be equal to max width.") + XCTAssertEqual(resizedImage.size.height, 100, "The height should be resized proportionally.") + } + + func testResizeHeightOnly() { + // An image where only the height exceeds the max size + let originalImage = createTestImage(size: NSSize(width: 400, height: 600)) + + let maxSize = MaxSize(width: 500, height: 300) + let resizedImage = originalImage.resizedOrOriginal(maxSize: maxSize) + + // The image should be resized proportionally based on height + XCTAssertEqual(resizedImage.size.width, 200, "The width should be resized proportionally.") + XCTAssertEqual( + resizedImage.size.height, maxSize.height, "The height should be equal to max height.") + } + + func testResizeExtremeAspectRatio() { + // An image (20:1) with an extreme aspect ratio (very wide) + let originalImage = createTestImage(size: NSSize(width: 2000, height: 100)) + + let maxSize = MaxSize(width: 600, height: 400) + let resizedImage = originalImage.resizedOrOriginal(maxSize: maxSize) + + // The resized image should be within the max size while maintaining aspect ratio + XCTAssertEqual(resizedImage.size.width, 600, "The width should be resized to max width") + XCTAssertEqual(resizedImage.size.height, 30, "The height should be resized proportionally.") + } + + func testResizeImageWithSameAspectRatio() { + let originalImage = createTestImage(size: NSSize(width: 800, height: 400)) + + let maxSize = MaxSize(width: 600, height: 300) + let resizedImage = originalImage.resizedOrOriginal(maxSize: maxSize) + + XCTAssertEqual( + resizedImage.size.width, maxSize.width, + "Width should be equal to max width when the aspect ratio is the same") + XCTAssertEqual( + resizedImage.size.height, maxSize.height, + "Height should be equal to height width when the aspect ratio is the same") + } + + func testResizedOrOriginalWithUndefinedSize() { + let image = createTestImage(size: NSSize(width: 300, height: 200)) + let resizedImage = image.resizedOrOriginal(maxSize: MaxSize()) + + XCTAssertEqual( + image.size.width, resizedImage.size.width, + "Should return the original image without resizing.") + XCTAssertEqual( + image.size.height, resizedImage.size.height, + "Should return the original image without resizing.") + } + + func testShouldResize() { + let imageSize = NSSize(width: 400, height: 600) + let image = NSImage(size: imageSize) + + XCTAssertFalse( + image.shouldResize(maxSize: MaxSize()), + "Should not resize when both the width and height are nil." + ) + + XCTAssertTrue( + image.shouldResize(maxSize: MaxSize(width: 300, height: 500)), + "Should resize when image size larger than max size." + ) + XCTAssertTrue( + image.shouldResize(maxSize: MaxSize(width: 300)), + "Should resize when image width larger than max width." + ) + XCTAssertTrue( + image.shouldResize(maxSize: MaxSize(height: 500)), + "Should resize when image height larger than max height." + ) + + XCTAssertFalse( + image.shouldResize(maxSize: MaxSize(width: 500, height: 700)), + "Should not resize when image size smaller than max size." + ) + XCTAssertFalse( + image.shouldResize(maxSize: MaxSize(width: 500)), + "Should not resize when image width smaller than max width." + ) + XCTAssertFalse( + image.shouldResize(maxSize: MaxSize(height: 700)), + "Should not resize when image height smaller than max height." + ) + + XCTAssertFalse( + image.shouldResize(maxSize: MaxSize(width: imageSize.width, height: imageSize.height)), + "Should not resize when image size equal max size." + ) + + XCTAssertTrue( + image.shouldResize(maxSize: MaxSize(width: 350, height: 700)), + "Should resize when image width larger than max width and image height less than max height." + ) + XCTAssertTrue( + image.shouldResize(maxSize: MaxSize(width: 450, height: 500)), + "Should resize when image height is larger than max height and image width less than max width" + ) + + } + + func testHasAnyDimension() { + XCTAssertFalse( + MaxSize(width: nil, height: nil).hasAnyDimension(), + "Should not resize when both width and height are nil.") + XCTAssertTrue( + MaxSize(width: 20, height: nil).hasAnyDimension(), + "Should resize when width is specified and height is nil.") + XCTAssertTrue( + MaxSize(width: nil, height: 20).hasAnyDimension(), + "Should resize when height is specified and width is nil.") + XCTAssertTrue( + MaxSize(width: 20, height: 20).hasAnyDimension(), + "Should resize when both width and height are specified.") + } + + func testMaxSizeToNSSize_withDefinedWidthAndHeight() { + let image = createTestImage(size: NSSize(width: 50, height: 50)) + let maxSize = MaxSize(width: 32, height: 96) + + XCTAssertEqual( + maxSize.toNSSize(image: image).width, maxSize.width, + "Expected width to match MaxSize width.") + XCTAssertEqual( + maxSize.toNSSize(image: image).height, maxSize.height, + "Expected height to match MaxSize height.") + } + + func testMaxSizeToNSSize_withDefinedWidthOnly() { + let image = createTestImage(size: NSSize(width: 50, height: 50)) + let maxSize = MaxSize(width: 128) + + XCTAssertEqual( + maxSize.toNSSize(image: image).width, maxSize.width, + "Expected width to match MaxSize width.") + XCTAssertEqual( + maxSize.toNSSize(image: image).height, image.size.height, + "Expected height to default to image height when MaxSize height is nil.") + } + + func testMaxSizeToNSSize_withDefinedHeightOnly() { + let image = createTestImage(size: NSSize(width: 50, height: 50)) + let maxSize = MaxSize(height: 64) + + XCTAssertEqual( + maxSize.toNSSize(image: image).width, image.size.width, + "Expected width to default to image width when MaxSize width is nil.") + XCTAssertEqual( + maxSize.toNSSize(image: image).height, maxSize.height, + "Expected height to match MaxSize height.") + } + + func testMaxSizeToNSSize_withUndefinedWidthAndHeight() { + let image = createTestImage(size: NSSize(width: 50, height: 50)) + let maxSize = MaxSize() + + XCTAssertEqual( + maxSize.toNSSize(image: image).width, image.size.width, + "Expected width to default to image width when MaxSize width is nil.") + XCTAssertEqual( + maxSize.toNSSize(image: image).height, image.size.height, + "Expected height to default to image height when MaxSize height is nil.") + } + +} diff --git a/packages/image_picker/image_picker_macos/example/macos/RunnerTests/RunnerTests.swift b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/RunnerTests.swift new file mode 100644 index 000000000000..6b6becdf3241 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/macos/RunnerTests/RunnerTests.swift @@ -0,0 +1,94 @@ +// 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 XCTest + +@testable import image_picker_macos + +final class RunnerTests: XCTestCase { + + func testSupportsPHPicker() { + let imagePicker = ImagePickerImpl() + if #available(macOS 13.0, *) { + XCTAssertTrue( + imagePicker.supportsPHPicker(), + "PHPicker is expected to be supported on macOS 13.0 and newer versions.") + } else { + XCTAssertFalse( + imagePicker.supportsPHPicker(), + "PHPicker is expected to be unsupported on macOS versions older than 13.0.") + } + } + + func testImageFileType() { + XCTAssertEqual( + imageFileType(quality: 100), NSBitmapImageRep.FileType.png, + "Quality 100 should return PNG file type.") + XCTAssertEqual( + imageFileType(quality: 99), NSBitmapImageRep.FileType.jpeg, + "Quality below 100 should return JPEG file type.") + XCTAssertEqual( + imageFileType(quality: nil), NSBitmapImageRep.FileType.png, + "Quality nil should return PNG file type.") + } + + func testImageFileExt() { + XCTAssertEqual( + imageFileExt(fileType: NSBitmapImageRep.FileType.png), "png", + "File extension for PNG should be 'png'.") + XCTAssertEqual( + imageFileExt(fileType: NSBitmapImageRep.FileType.jpeg), "jpeg", + "File extension for JPEG should be 'jpeg'.") + } + + func testGenerateUniqueImageFileName() { + let fileType = NSBitmapImageRep.FileType.jpeg + let generatedFileName = generateUniqueImageFileName(imageFileType: fileType) + let expectedExtension = imageFileExt(fileType: fileType) + + // Extract the UUID part of the generated file name + let uuidStringFromFile = generatedFileName.replacingOccurrences( + of: ".\(expectedExtension)", with: "") + + let fileUUID = UUID(uuidString: uuidStringFromFile) + + XCTAssertNotNil(fileUUID, "Generated file name should start with a valid UUID.") + XCTAssertTrue( + generatedFileName.hasSuffix(".\(expectedExtension)"), + "Generated file name should have a '\(expectedExtension)' extension.") + } + + func testGenerateTempImageFilePath() { + let fileType: NSBitmapImageRep.FileType = NSBitmapImageRep.FileType.png + let filePath = generateTempImageFilePath(imageFileType: fileType) + let fileExists = FileManager.default.fileExists(atPath: filePath.path) + + XCTAssertFalse(fileExists, "The file at path \(filePath) should not exist.") + XCTAssertEqual(filePath.pathExtension, "png", "The file path should have a .png extension.") + XCTAssertTrue( + filePath.absoluteString.hasPrefix(FileManager.default.temporaryDirectory.absoluteString), + "The file path should be in the temporary directory.") + + XCTAssertTrue(filePath.isFileURL, "The generated path should be a file URL.") + + let anotherFilePath = generateTempImageFilePath(imageFileType: fileType) + XCTAssertNotEqual(filePath, anotherFilePath, "The generated file paths should be unique.") + } + + func testPathString() { + let tempDirectory = FileManager.default.temporaryDirectory + let fileURL = tempDirectory.appendingPathComponent("flutter.dart") + + XCTAssertEqual( + fileURL.path, fileURL.pathString(), + "Expected pathString() to match `URL.path` for the current URL.") + + if #available(macOS 13.0, *) { + XCTAssertEqual( + fileURL.path(), fileURL.pathString(), + "Expected pathString() to match `URL.path()` for macOS 13.0 and later.") + } + } + +} diff --git a/packages/image_picker/image_picker_macos/example/macos/RunnerUITests/RunnerUITests.swift b/packages/image_picker/image_picker_macos/example/macos/RunnerUITests/RunnerUITests.swift new file mode 100644 index 000000000000..df50b4c08125 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/macos/RunnerUITests/RunnerUITests.swift @@ -0,0 +1,32 @@ +// 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 FlutterMacOS +import XCTest + +@testable import image_picker_macos + +/// The specified amount of time for waiting to check if an element exists. +let kElementWaitingTime: TimeInterval = 30 + +final class RunnerUITests: XCTestCase { + + var app: XCUIApplication! + + override func setUp() { + continueAfterFailure = false + app = XCUIApplication() + app.launch() + } + + override func tearDown() { + app.terminate() + } + + @MainActor + func testImagePicker() throws { + // TODO(EchoEllet): Lacks native UI tests https://discord.com/channels/608014603317936148/1300517990957056080/1300518056690188361 + // https://github.com/flutter/flutter/issues/70234 + } +} diff --git a/packages/image_picker/image_picker_macos/example/pubspec.yaml b/packages/image_picker/image_picker_macos/example/pubspec.yaml index ab0d82bbc305..bdbd284f3c7f 100644 --- a/packages/image_picker/image_picker_macos/example/pubspec.yaml +++ b/packages/image_picker/image_picker_macos/example/pubspec.yaml @@ -24,6 +24,8 @@ dependencies: dev_dependencies: flutter_test: sdk: flutter + integration_test: + sdk: flutter flutter: uses-material-design: true diff --git a/packages/image_picker/image_picker_macos/example/test_driver/integration_test.dart b/packages/image_picker/image_picker_macos/example/test_driver/integration_test.dart new file mode 100644 index 000000000000..4f10f2a522f3 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/test_driver/integration_test.dart @@ -0,0 +1,7 @@ +// 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:integration_test/integration_test_driver.dart'; + +Future main() => integrationDriver(); diff --git a/packages/image_picker/image_picker_macos/lib/image_picker_macos.dart b/packages/image_picker/image_picker_macos/lib/image_picker_macos.dart index 9e9447a5710c..d509d019a3c1 100644 --- a/packages/image_picker/image_picker_macos/lib/image_picker_macos.dart +++ b/packages/image_picker/image_picker_macos/lib/image_picker_macos.dart @@ -7,6 +7,8 @@ import 'package:file_selector_platform_interface/file_selector_platform_interfac import 'package:flutter/foundation.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; +import 'src/messages.g.dart'; + /// The macOS implementation of [ImagePickerPlatform]. /// /// This class implements the `package:image_picker` functionality for @@ -15,6 +17,10 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { /// Constructs a platform implementation. ImagePickerMacOS(); + /// The platform API generated by Pigeon to communicate with native macOS using a method channel. + /// Used only when [useMacOSPHPicker] is `true` for **PHPicker implementation**. + final ImagePickerApi _hostApi = ImagePickerApi(); + /// The file selector used to prompt the user to select images or videos. @visibleForTesting static FileSelectorPlatform fileSelector = FileSelectorMacOS(); @@ -24,6 +30,42 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { ImagePickerPlatform.instance = ImagePickerMacOS(); } + /// Sets [ImagePickerMacOS] to use [PHPicker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) + /// which is **only supported on macOS 13.0+**. + /// + /// Will fallback to [file_selector_macos](https://pub.dev/packages/file_selector_macos) + /// if [useMacOSPHPicker] is `false` or the macOS version doesn't support + /// this feature. + /// + /// Currently defaults to `false`. + /// + /// **Note**: This implementation depends on the photos in the [Photos for macOS App](https://www.apple.com/in/macos/photos/), + /// if the user didn't open the app or import any photos to the app, + /// they will see: `No photos` or `No Photos or Videos` message even if they + /// have them as files on their desktop. + /// + /// Supports picking an image, multi-image, video, media, and multiple media. + bool useMacOSPHPicker = false; + + // TODO(EchoEllet): shouldUsePHPicker() and supportsPHPicker() should not be public, avoid using @visibleForTesting + + /// Return `true` if the current macOS version supports [useMacOSPHPicker]. + /// + /// The [PHPicker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) + /// is **supported on macOS 13.0+** + @visibleForTesting + Future supportsPHPicker() => _hostApi.supportsPHPicker(); + + /// Returns `true` if [ImagePickerMacOS] should use [PHPicker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller). + /// + /// See also: + /// + /// * [useMacOSPHPicker] to check whether **PHPicker** should be preferred over [file_selector_macos](https://pub.dev/packages/file_selector_macos). + /// * [supportsPHPicker] to verify if the current macOS version supports **PHPicker**. + @visibleForTesting + Future shouldUsePHPicker() async => + await supportsPHPicker() && useMacOSPHPicker; + // This is soft-deprecated in the platform interface, and is only implemented // for compatibility. Callers should be using getImageFromSource. @override @@ -83,8 +125,9 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { preferredCameraDevice: preferredCameraDevice)); } - // [ImagePickerOptions] options are not currently supported. If any - // of its fields are set, they will be silently ignored. + // [ImagePickerOptions] options are currently only supported when using + // PHPicker implementation. If any of its fields are set, + // they will be silently ignored. // // If source is `ImageSource.camera`, a `StateError` will be thrown // unless a [cameraDelegate] is set. @@ -97,9 +140,18 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { case ImageSource.camera: return super.getImageFromSource(source: source); case ImageSource.gallery: - // TODO(stuartmorgan): Add a native implementation that can use - // PHPickerViewController on macOS 13+, with this as a fallback for - // older OS versions: https://github.com/flutter/flutter/issues/125829. + if (await shouldUsePHPicker()) { + final String? imagePath = (await _hostApi.pickImages( + _imageOptionsToImageSelectionOptions(options), + GeneralOptions(limit: 1), + )) + .firstOrNull; + if (imagePath == null) { + return null; + } + + return XFile(imagePath); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.image']); final XFile? file = await fileSelector @@ -130,6 +182,15 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { preferredCameraDevice: preferredCameraDevice, maxDuration: maxDuration); case ImageSource.gallery: + if (await shouldUsePHPicker()) { + final String? videoPath = + (await _hostApi.pickVideos(GeneralOptions(limit: 1))).firstOrNull; + if (videoPath == null) { + return null; + } + + return XFile(videoPath); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.movie']); final XFile? file = await fileSelector @@ -141,18 +202,41 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { throw UnimplementedError('Unknown ImageSource: $source'); } - // `maxWidth`, `maxHeight`, and `imageQuality` arguments are not currently - // supported. If any of these arguments are supplied, they will be silently - // ignored. + // This is soft-deprecated in the platform interface, and is only implemented + // for compatibility. Callers should be using getMultiImageWithOptions. @override Future> getMultiImage({ double? maxWidth, double? maxHeight, int? imageQuality, }) async { - // TODO(stuartmorgan): Add a native implementation that can use - // PHPickerViewController on macOS 13+, with this as a fallback for - // older OS versions: https://github.com/flutter/flutter/issues/125829. + return getMultiImageWithOptions( + options: MultiImagePickerOptions( + imageOptions: ImageOptions( + imageQuality: imageQuality, + maxHeight: maxHeight, + maxWidth: maxWidth, + ), + ), + ); + } + + // [MultiImagePickerOptions] options are currently only + // supported when using PHPicker implementation. If any of these arguments are supplied, they will be silently + // ignored. + @override + Future> getMultiImageWithOptions({ + MultiImagePickerOptions options = const MultiImagePickerOptions(), + }) async { + if (await shouldUsePHPicker()) { + final List images = await _hostApi.pickImages( + _imageOptionsToImageSelectionOptions(options.imageOptions), + GeneralOptions( + limit: options.limit ?? 0, + ), + ); + return images.map((String imagePath) => XFile(imagePath)).toList(); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.image']); final List files = await fileSelector @@ -160,11 +244,35 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { return files; } - // `maxWidth`, `maxHeight`, and `imageQuality` arguments are not currently - // supported. If any of these arguments are supplied, they will be silently + ImageSelectionOptions _imageOptionsToImageSelectionOptions( + ImageOptions imageOptions, + ) { + return ImageSelectionOptions( + quality: imageOptions.imageQuality ?? 100, + maxSize: MaxSize( + width: imageOptions.maxWidth, + height: imageOptions.maxHeight, + ), + ); + } + + // [ImageOptions] options are currently only + // supported when using PHPicker implementation. If any of these arguments are supplied, they will be silently // ignored. @override Future> getMedia({required MediaOptions options}) async { + if (await shouldUsePHPicker()) { + final List images = await _hostApi.pickMedia( + MediaSelectionOptions( + imageSelectionOptions: + _imageOptionsToImageSelectionOptions(options.imageOptions), + ), + GeneralOptions( + limit: options.limit ?? (options.allowMultiple ? 0 : 1), + ), + ); + return images.map((String mediaPath) => XFile(mediaPath)).toList(); + } const XTypeGroup typeGroup = XTypeGroup( label: 'images and videos', extensions: ['public.image', 'public.movie']); diff --git a/packages/image_picker/image_picker_macos/lib/src/messages.g.dart b/packages/image_picker/image_picker_macos/lib/src/messages.g.dart new file mode 100644 index 000000000000..19d2bf53a063 --- /dev/null +++ b/packages/image_picker/image_picker_macos/lib/src/messages.g.dart @@ -0,0 +1,297 @@ +// 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. +// Autogenerated from Pigeon (v22.6.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, prefer_null_aware_operators, omit_local_variable_types, unused_shown_name, unnecessary_import, no_leading_underscores_for_local_identifiers + +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; + +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; + +PlatformException _createConnectionError(String channelName) { + return PlatformException( + code: 'channel-error', + message: 'Unable to establish connection on channel: "$channelName".', + ); +} + +List wrapResponse({Object? result, PlatformException? error, bool empty = false}) { + if (empty) { + return []; + } + if (error == null) { + return [result]; + } + return [error.code, error.message, error.details]; +} + +/// The common options between [ImageSelectionOptions], [VideoSelectionOptions] +/// and [MediaSelectionOptions]. +class GeneralOptions { + GeneralOptions({ + required this.limit, + }); + + /// The value `0` means no limit. + int limit; + + Object encode() { + return [ + limit, + ]; + } + + static GeneralOptions decode(Object result) { + result as List; + return GeneralOptions( + limit: result[0]! as int, + ); + } +} + +/// Represents the maximum size with [width] and [height] dimensions. +class MaxSize { + MaxSize({ + this.width, + this.height, + }); + + double? width; + + double? height; + + Object encode() { + return [ + width, + height, + ]; + } + + static MaxSize decode(Object result) { + result as List; + return MaxSize( + width: result[0] as double?, + height: result[1] as double?, + ); + } +} + +/// Options for image selection and output. +class ImageSelectionOptions { + ImageSelectionOptions({ + this.maxSize, + required this.quality, + }); + + /// If set, the max size that the image should be resized to fit in. + MaxSize? maxSize; + + /// The quality of the output image, from 0-100. + /// + /// 100 indicates original quality. + int quality; + + Object encode() { + return [ + maxSize, + quality, + ]; + } + + static ImageSelectionOptions decode(Object result) { + result as List; + return ImageSelectionOptions( + maxSize: result[0] as MaxSize?, + quality: result[1]! as int, + ); + } +} + +class MediaSelectionOptions { + MediaSelectionOptions({ + required this.imageSelectionOptions, + }); + + ImageSelectionOptions imageSelectionOptions; + + Object encode() { + return [ + imageSelectionOptions, + ]; + } + + static MediaSelectionOptions decode(Object result) { + result as List; + return MediaSelectionOptions( + imageSelectionOptions: result[0]! as ImageSelectionOptions, + ); + } +} + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is GeneralOptions) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is MaxSize) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is ImageSelectionOptions) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is MediaSelectionOptions) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return GeneralOptions.decode(readValue(buffer)!); + case 130: + return MaxSize.decode(readValue(buffer)!); + case 131: + return ImageSelectionOptions.decode(readValue(buffer)!); + case 132: + return MediaSelectionOptions.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +class ImagePickerApi { + /// Constructor for [ImagePickerApi]. The [binaryMessenger] named argument is + /// available for dependency injection. If it is left null, the default + /// BinaryMessenger will be used which routes to the host platform. + ImagePickerApi({BinaryMessenger? binaryMessenger, String messageChannelSuffix = ''}) + : pigeonVar_binaryMessenger = binaryMessenger, + pigeonVar_messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + final BinaryMessenger? pigeonVar_binaryMessenger; + + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + final String pigeonVar_messageChannelSuffix; + + Future supportsPHPicker() async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.supportsPHPicker$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send(null) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as bool?)!; + } + } + + Future> pickImages(ImageSelectionOptions options, GeneralOptions generalOptions) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([options, generalOptions]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } + + /// Currently, multi-video selection is unimplemented. + Future> pickVideos(GeneralOptions generalOptions) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickVideos$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([generalOptions]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } + + Future> pickMedia(MediaSelectionOptions options, GeneralOptions generalOptions) async { + final String pigeonVar_channelName = 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia$pigeonVar_messageChannelSuffix'; + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final List? pigeonVar_replyList = + await pigeonVar_channel.send([options, generalOptions]) as List?; + if (pigeonVar_replyList == null) { + throw _createConnectionError(pigeonVar_channelName); + } else if (pigeonVar_replyList.length > 1) { + throw PlatformException( + code: pigeonVar_replyList[0]! as String, + message: pigeonVar_replyList[1] as String?, + details: pigeonVar_replyList[2], + ); + } else if (pigeonVar_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (pigeonVar_replyList[0] as List?)!.cast(); + } + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos.podspec b/packages/image_picker/image_picker_macos/macos/image_picker_macos.podspec new file mode 100644 index 000000000000..f493353d77f8 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos.podspec @@ -0,0 +1,23 @@ +# +# To learn more about a Podspec see http://guides.cocoapods.org/syntax/podspec.html. +# Run `pod lib lint image_picker_macos.podspec` to validate before publishing. +# +Pod::Spec.new do |s| + s.name = 'image_picker_macos' + s.version = '0.0.1' + s.summary = 'Flutter plugin that shows an image picker.' + s.description = <<-DESC +A Flutter plugin for picking images from the image library, and taking new pictures with the camera. +Downloaded by pub (not CocoaPods). + DESC + s.homepage = 'https://github.com/flutter/packages' + s.license = { :type => 'BSD', :file => '../LICENSE' } + s.author = { 'Flutter Team' => 'flutter-dev@googlegroups.com' } + s.source = { :http => 'https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_macos' } + s.source_files = 'image_picker_macos/Sources/image_picker_macos/**/*.swift' + s.dependency 'FlutterMacOS' + + s.platform = :osx, '10.11' + s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } + s.swift_version = '5.0' +end diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Package.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Package.swift new file mode 100644 index 000000000000..19042756f6c7 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Package.swift @@ -0,0 +1,28 @@ +// swift-tools-version: 5.9 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +// 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 PackageDescription + +let package = Package( + name: "image_picker_macos", + platforms: [ + .macOS("10.11") + ], + products: [ + .library(name: "image-picker-macos", targets: ["image_picker_macos"]) + ], + dependencies: [], + targets: [ + .target( + name: "image_picker_macos", + dependencies: [], + resources: [ + .process("Resources") + ] + ) + ] +) diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageCompress.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageCompress.swift new file mode 100644 index 000000000000..5a6dedd58dae --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageCompress.swift @@ -0,0 +1,71 @@ +// 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 FlutterMacOS +import Foundation + +/// Determines if the image should be compressed based on the quality. +/// +/// - Parameter quality: The quality level (0-100). A quality less than 100 indicates compression. +/// - Returns: Whether the image should be compressed. +func shouldCompressImage(quality: Int64?) -> Bool { + return quality != nil && quality != 100 +} + +extension NSImage { + /// Compresses the image to the specified quality. + /// + /// - Parameter quality: The quality of the image (0 to 100). + /// - Returns: An optional `NSImage` that represents the compressed image. + func compressed(quality: Int64) throws -> NSImage { + assert(quality != 100, "Quality 100 means no compression.") + assert(quality >= 0, "Quality can't be negative.") + + guard let tiffData = self.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData) + else { + // TODO(EchoEllet): Is there a convention for the error code? ImageConversionError or IMAGE_CONVERSION_ERROR or image-conversion-error. Update all codes. + throw PigeonError( + code: "ImageConversionError", message: "Failed to convert NSImage to TIFF data.", + details: nil) + } + + // Convert quality from 0-100 to 0.0-1.0 + let compressionQuality = max(0.0, min(1.0, Double(quality) / 100.0)) + + guard + let compressedData = bitmapRep.representation( + using: .jpeg, properties: [.compressionFactor: compressionQuality]) + else { + throw PigeonError( + code: "CompressionError", message: "Failed to compress image.", details: nil) + } + + guard let compressedImage = NSImage(data: compressedData) else { + throw PigeonError( + code: "ImageCreationError", message: "Failed to create NSImage from compressed data.", + details: nil) + } + + return compressedImage + } + + /// Returns the original image or a compressed version based on the specified quality. + /// + /// - Parameter quality: The compression quality as an optional value. + /// If `nil` or if compression is not needed, the original image is returned. + /// - Returns: The original or compressed `NSImage`. + func compressedOrOriginal(quality: Int64?) throws -> NSImage { + if !shouldCompressImage(quality: quality) { + return self + } + assert( + quality != nil, + "The quality expected to be not nil due to check using \(shouldCompressImage).") + guard let quality = quality else { + return self + } + return try compressed(quality: quality) + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerImpl.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerImpl.swift new file mode 100644 index 000000000000..f6cb5649ceee --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerImpl.swift @@ -0,0 +1,519 @@ +// 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 Foundation +import PhotosUI + +/// An implementation of [image_picker](https://pub.dev/packages/image_picker) for macOS using [PHPicker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller). +/// +/// The package [image_picker_macos](https://pub.dev/packages/image_picker_macos) depends on [file_selector_macos](https://pub.dev/packages/file_selector_macos) +/// for picking images, videos, and media. It has limited support for resizing and compression and uses the system file picker, this implementation is used by the Dart plugin +/// to use [PHPickerViewController](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) which is supported on macOS 13.0+ +/// otherwise fallback to file selector if unsupported or the user prefers the file selector implementation. +class ImagePickerImpl: NSObject, ImagePickerApi { + /// Returns `true` if the current macOS version supports this feature. + /// + /// `PHPicker` is supported on macOS 13.0+. + /// For more information, see [PHPickerViewController](https://developer.apple.com/documentation/photokit/phpickerviewcontroller). + func supportsPHPicker() -> Bool { + guard #available(macOS 13.0, *) else { + return false + } + return true + } + + private var pickImagesDelegate: PickImagesDelegate? + private var pickVideosDelegate: PickVideosDelegate? + private var pickMediaDelegate: PickMediaDelegate? + + func pickImages( + options: ImageSelectionOptions, generalOptions: GeneralOptions, + completion: @escaping (Result<[String], any Error>) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion( + .failure( + PigeonError( + code: "UNSUPPORTED_PHPICKER", + message: + "PHPicker is only supported on macOS 13.0 or newer. Use \(supportsPHPicker) to check.", + details: nil))) + return + } + + var config = PHPickerConfiguration() + config.selectionLimit = Int(generalOptions.limit) + config.filter = .images + + let picker = PHPickerViewController(configuration: config) + + pickImagesDelegate = PickImagesDelegate( + completion: completion, + options: options + ) + picker.delegate = pickImagesDelegate + + showPHPicker( + picker, + noActiveWindow: { + completion( + .failure( + PigeonError( + code: "WINDOW_NOT_FOUND", message: "No active window to present the picker.", + details: nil))) + }) + } + + func pickVideos( + generalOptions: GeneralOptions, completion: @escaping (Result<[String], any Error>) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion( + .failure( + PigeonError( + code: "UNSUPPORTED_PHPICKER", + message: + "PHPicker is only supported on macOS 13.0 or newer. Use \(supportsPHPicker) to check.", + details: nil))) + return + } + + if generalOptions.limit != nil && generalOptions.limit != 1 { + completion( + .failure( + PigeonError( + code: "UNIMPLEMENTED", message: "Multi-video selection is not implemented", details: nil + ))) + return + } + + var config = PHPickerConfiguration() + config.selectionLimit = 1 + config.filter = .videos + + let picker = PHPickerViewController(configuration: config) + pickVideosDelegate = PickVideosDelegate(completion: completion) + picker.delegate = pickVideosDelegate + + showPHPicker( + picker, + noActiveWindow: { + completion( + .failure( + PigeonError( + code: "WINDOW_NOT_FOUND", message: "No active window to present the picker.", + details: nil))) + }) + } + + func pickMedia( + options: MediaSelectionOptions, generalOptions: GeneralOptions, + completion: @escaping (Result<[String], any Error>) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion( + .failure( + PigeonError( + code: "UNSUPPORTED_PHPICKER", + message: + "PHPicker is only supported on macOS 13.0 or newer. Use \(supportsPHPicker) to check.", + details: nil))) + return + } + + var config = PHPickerConfiguration() + config.selectionLimit = Int(generalOptions.limit) + config.filter = PHPickerFilter.any(of: [.images, .videos]) + + let picker = PHPickerViewController(configuration: config) + pickMediaDelegate = PickMediaDelegate(completion: completion, options: options) + picker.delegate = pickMediaDelegate + + showPHPicker( + picker, + noActiveWindow: { + completion( + .failure( + PigeonError( + code: "WINDOW_NOT_FOUND", message: "No active window to present the picker.", + details: nil))) + }) + } + + @available(macOS 13, *) + private func showPHPicker(_ picker: PHPickerViewController, noActiveWindow: @escaping () -> Void) + { + guard let window = NSApplication.shared.keyWindow else { + noActiveWindow() + return + } + // TODO(EchoEllet): IMPORTANT The window size of the picker is smaller than expected, see the video in https://discord.com/channels/608014603317936148/1295165633931120642/1295470850283147335 + window.contentViewController?.presentAsSheet(picker) + } +} + +class PickImagesDelegate: PHPickerViewControllerDelegate { + private let completion: ((Result<[String], any Error>) -> Void) + private let options: ImageSelectionOptions + + init( + completion: @escaping ((Result<[String], any Error>) -> Void), options: ImageSelectionOptions + ) { + self.completion = completion + self.options = options + } + + @available(macOS 13, *) + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(nil) + + if results.isEmpty { + completion(.success([])) + return + } + + var savedFilePaths: [String] = [] + + Task { + for result in results { + let itemProvider = result.itemProvider + guard itemProvider.canLoadObject(ofClass: NSImage.self) else { + completion( + .failure( + PigeonError( + code: "INVALID_SELECTION", message: "One of the selected items is not an image", + details: nil))) + return + } + + guard + let tempImagePath = await PickImageHandler( + completion: completion, options: options + ).processAndSave(itemProvider: itemProvider) + else { return } + savedFilePaths.append(tempImagePath) + } + completion(.success(savedFilePaths)) + } + } +} + +// Currently, multi-video selection is unimplemented. +class PickVideosDelegate: PHPickerViewControllerDelegate { + private let completion: ((Result<[String], any Error>) -> Void) + + init(completion: @escaping ((Result<[String], any Error>) -> Void)) { + self.completion = completion + } + + @available(macOS 13, *) + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(nil) + + guard let itemProvider = results.first?.itemProvider else { + completion(.success([])) + return + } + + let canLoadVideo = itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) + if !canLoadVideo { + completion( + .failure( + PigeonError( + code: "INVALID_SELECTION", message: "The selected item is not a video", details: nil))) + return + } + + Task { + guard + let tempVideoPath = await PickVideoHandler(completion: completion) + .processAndSave(itemProvider: itemProvider) + else { return } + + completion(.success([tempVideoPath])) + } + + } +} + +class PickMediaDelegate: PHPickerViewControllerDelegate { + private let completion: ((Result<[String], any Error>) -> Void) + private let options: MediaSelectionOptions + + init(completion: @escaping (Result<[String], any Error>) -> Void, options: MediaSelectionOptions) + { + self.completion = completion + self.options = options + } + + @available(macOS 13, *) + func picker(_ picker: PHPickerViewController, didFinishPicking results: [PHPickerResult]) { + picker.dismiss(nil) + + if results.isEmpty { + completion(.success([])) + return + } + + var savedFilePaths: [String] = [] + + Task { + for result in results { + let itemProvider = result.itemProvider + + let canLoadImage = itemProvider.canLoadObject(ofClass: NSImage.self) + if canLoadImage { + guard + let tempImagePath = await PickImageHandler( + completion: completion, options: options.imageSelectionOptions + ).processAndSave(itemProvider: itemProvider) + else { return } + savedFilePaths.append(tempImagePath) + } + + let canLoadVideo = itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) + if canLoadVideo { + guard + let tempVideoPath = await PickVideoHandler(completion: completion).processAndSave( + itemProvider: itemProvider) + else { return } + savedFilePaths.append(tempVideoPath) + } + } + + completion(.success(savedFilePaths)) + } + } + +} + +extension NSItemProvider { + @available(macOS 10.15, *) + @MainActor + func loadObject(ofClass: T.Type) async throws -> T { + return try await withCheckedThrowingContinuation { continuation in + loadObject(ofClass: ofClass) { (object, error) in + if let error = error { + continuation.resume(throwing: error) + } else if let object = object as? T { + continuation.resume(returning: object) + } else { + continuation.resume(throwing: NSError(domain: "INVALID_OBJECT", code: -1, userInfo: nil)) + } + } + } + } + @available(macOS 13.0, *) + @MainActor + func loadDataRepresentation(for contentType: UTType) async throws -> Data { + return try await withCheckedThrowingContinuation { continuation in + loadDataRepresentation(for: contentType) { (data, error) in + if let error = error { + continuation.resume(throwing: error) + } else if let data = data as? Data { + continuation.resume(returning: data) + } else { + continuation.resume(throwing: NSError(domain: "INVALID_OBJECT", code: -1, userInfo: nil)) + } + } + } + } +} + +/// Gets the appropriate file type based on whether the image should be compressed. +/// +/// - Parameter quality: Determines if the image should be compressed based on the quality. +/// - Returns: The image file type (`png` or `jpeg`). +func imageFileType(quality: Int64?) -> NSBitmapImageRep.FileType { + let shouldCompress = shouldCompressImage(quality: quality) + // TODO(EchoEllet): The picked image can be JPEG even if it can represented as a PNG + return shouldCompress ? NSBitmapImageRep.FileType.jpeg : NSBitmapImageRep.FileType.png +} + +/// Gets the file extension based from the image file type. +/// +/// - Parameter fileType: The image file type. +/// - Returns: The image file extension. +func imageFileExt(fileType: NSBitmapImageRep.FileType) -> String { + assert( + [NSBitmapImageRep.FileType.png, NSBitmapImageRep.FileType.jpeg].contains(fileType), + "Expected the image file type to be either PNG or JPEG." + ) + switch fileType { + case .jpeg: return "jpeg" + case .png: return "png" + default: + fatalError( + "Case is not covered since only PNG and JPEG will be used: \(String(describing: fileType))") + } +} + +/// Generates a unique image file name with a UUID and the specified file type. +/// +/// The file name includes a UUID followed by the appropriate file extension. +/// For example, if the file type is JPEG, the result will be `UUID.jpeg`. +/// +/// - Parameter imageFileType: The file type for determining the extension. +/// - Returns: A unique image file name. +func generateUniqueImageFileName(imageFileType: NSBitmapImageRep.FileType) -> String { + return UUID().uuidString + ".\(imageFileExt(fileType: imageFileType))" +} + +/// Generates a unique file path for a temporary image in the system's temporary directory. +/// +/// - Parameter imageFileType: The file type of the image (e.g., PNG, JPEG). +/// - Returns: A `URL` representing the unique file path for the temporary image. +func generateTempImageFilePath(imageFileType: NSBitmapImageRep.FileType) -> URL { + let tempDirectory = FileManager.default.temporaryDirectory + + let uniqueFileName = generateUniqueImageFileName(imageFileType: imageFileType) + let filePath = tempDirectory.appendingPathComponent(uniqueFileName) + return filePath +} + +/// Shared image handling between `PickImageDelegate` and `PickMediaDelegate`. +class PickImageHandler { + let completion: ((Result<[String], any Error>) -> Void) + let options: ImageSelectionOptions + + init(completion: @escaping (Result<[String], any Error>) -> Void, options: ImageSelectionOptions) + { + self.completion = completion + self.options = options + } + + /// Load an image, process it if needed, copy it to a temporary directory, and return the file path. + /// + /// Returns `nil` if an error occurs, and handles. + @available(macOS 10.15, *) + func processAndSave(itemProvider: NSItemProvider) async -> String? { + do { + let image = try await itemProvider.loadObject(ofClass: NSImage.self) + guard let processedImage = processImage(image) else { return nil } + guard let tempImagePath = copyImageToTempDir(processedImage) else { return nil } + return tempImagePath + } catch { + completion( + .failure( + PigeonError( + code: "IMAGE_LOAD_FAILED", + message: "Error loading image: \(error.localizedDescription)", details: nil))) + return nil + } + } + + /// Copy an image to a temporary directory and return the file path. + /// + /// Returns `nil` if an error occurs, and handles. + private func copyImageToTempDir(_ image: NSImage) -> String? { + let imageFileType = imageFileType(quality: options.quality) + + guard let tiffData = image.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData), + let imageData = bitmapRep.representation(using: imageFileType, properties: [:]) + else { + completion( + .failure( + PigeonError( + code: "IMAGE_CONVERSION_FAILED", message: "Failed to convert NSImage to TIFF data.", + details: nil))) + return nil + } + + let filePath = generateTempImageFilePath(imageFileType: imageFileType) + + do { + try imageData.write(to: filePath) + return filePath.pathString() + } catch { + completion( + .failure( + PigeonError( + code: "IMAGE_SAVE_FAILED", message: "Error saving image to file: \(error)", details: nil + ))) + return nil + } + } + + /// Resize and compress the image if needed, then return the image. + /// + /// Returns `nil` if an error occurs, and handles. + private func processImage(_ image: NSImage) -> NSImage? { + do { + let processedImage = try image.resizedOrOriginal(maxSize: options.maxSize) + .compressedOrOriginal(quality: options.quality) + return processedImage + } catch { + completion( + .failure( + PigeonError( + code: "IMAGE_PROCESSING_FAILED", + message: "Error processing image: \(error.localizedDescription)", details: nil))) + return nil + } + } +} + +/// Shared image handling between `PickVideosDelegate` and `PickMediaDelegate`. +class PickVideoHandler { + let completion: ((Result<[String], any Error>) -> Void) + + init(completion: @escaping (Result<[String], any Error>) -> Void) { + self.completion = completion + } + + @available(macOS 13.0, *) + func processAndSave(itemProvider: NSItemProvider) async -> String? { + do { + let videoType = UTType.movie + let tempVideoFileName = generateUniqueVideoFileName( + videoFileExt: videoType.preferredFilenameExtension ?? "mov") + let tempVideoUrl = FileManager.default.temporaryDirectory.appendingPathComponent( + tempVideoFileName) + + let videoData = await try itemProvider.loadDataRepresentation(for: videoType) + try videoData.write(to: tempVideoUrl) + + let tempVideoPath = tempVideoUrl.pathString() + + return tempVideoPath + } catch { + completion( + .failure( + PigeonError( + code: "VIDEO_LOAD_FAILED", + message: "Error loading a video: \(error.localizedDescription)", details: nil))) + return nil + } + } +} + +/// Generates a unique video file name with a UUID and the specified file type. +/// +/// The file name includes a UUID followed by the appropriate file extension. +/// For example, if the file type is QuickTime movie, the result will be `UUID.mov`. +/// +/// - Parameter videoFileExt: The file extension. +/// - Returns: A unique image file name. +func generateUniqueVideoFileName(videoFileExt: String) -> String { + return UUID().uuidString + ".\(videoFileExt)" +} + +extension URL { + /// Returns the file path as a `String` for the current `URL`. + /// + /// On macOS 13 and later, this method calls `URL.path()`, + /// while for earlier versions it uses the `URL.path` property. + /// + /// Uses `URL.path()` on newer macOS versions to avoid future deprecation warnings for `URL.path`. + /// + /// - Returns: A `String` representing the file path. + func pathString() -> String { + if #available(macOS 13.0, *) { + return self.path() + } else { + return self.path + } + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerPlugin.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerPlugin.swift new file mode 100644 index 000000000000..fcf46fd735e0 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerPlugin.swift @@ -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 Cocoa +import FlutterMacOS + +public class ImagePickerPlugin: NSObject, FlutterPlugin { + public static func register(with registrar: FlutterPluginRegistrar) { + let messenger = registrar.messenger + let api = ImagePickerImpl() + ImagePickerApiSetup.setUp(binaryMessenger: messenger, api: api) + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageResize.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageResize.swift new file mode 100644 index 000000000000..9962b2e8a28d --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageResize.swift @@ -0,0 +1,94 @@ +// 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 FlutterMacOS +import Foundation + +extension NSImage { + /// Resizes the image to fit within the specified max size (width and height), + /// while maintaining the aspect ratio. + /// + /// - Parameter maxSize: The maximum allowed size (width and height). + /// - Returns: A resized `NSImage` that fits within the max dimensions. + func resized(maxSize: NSSize) -> NSImage { + let originalSize = self.size + + let widthScale = maxSize.width / originalSize.width + let heightScale = maxSize.height / originalSize.height + + let scaleFactor = min(widthScale, heightScale) + + let newSize = NSSize( + width: originalSize.width * scaleFactor, + height: originalSize.height * scaleFactor + ) + + let resizedImage = NSImage(size: newSize, flipped: false) { rect in + self.draw( + in: rect, from: NSRect(origin: .zero, size: originalSize), operation: .sourceOver, + fraction: 1.0) + return true + } + return resizedImage + } + + /// Returns the image resized to fit within the specified maximum size. + /// + /// If the image needs resizing based on `maxSize`, it is resized while maintaining + /// its aspect ratio. Otherwise, the original image is returned. + /// + /// - Parameter maxSize: The maximum width and height for the image. Return the original image if `nil`. + /// - Returns: A resized `NSImage` or the original image. + func resizedOrOriginal(maxSize: MaxSize?) -> NSImage { + guard let maxSize = maxSize else { + return self + } + return shouldResize(maxSize: maxSize) + ? self.resized(maxSize: maxSize.toNSSize(image: self)) : self + } + + /// Checks if the image needs resizing based on the provided max size. + /// Returns `false` if the max size has no dimensions or if the image is within the limits. + /// + /// - Parameter maxSize: The maximum allowable size for the image. + /// - Returns: `true` if the image exceeds either the max width or height; otherwise, `false`. + func shouldResize(maxSize: MaxSize) -> Bool { + if !maxSize.hasAnyDimension() { + return false + } + let imageSize = self.size + + if let maxWidth = maxSize.width, imageSize.width > maxWidth { + return true + } + if let maxHeight = maxSize.height, imageSize.height > maxHeight { + return true + } + + // No resizing needed if both dimensions are within the limits + return false + } +} + +extension MaxSize { + /// Returns `true` if either width or height is not nil. + func hasAnyDimension() -> Bool { + return self.width != nil || self.height != nil + } + + /// Converts a `MaxSize`, which contains optional width and height values, + /// into a non-optional `NSSize`. If either the width or height is not provided (`nil`), + /// It defaults to the original image size. + /// + /// - Parameter image: An `NSImage` used to provide default width and height values + /// if the corresponding dimensions in `MaxSize` are not defined. + /// - Returns: A `NSSize` with the appropriate width and height (non-optional). + func toNSSize(image: NSImage) -> NSSize { + let imageSize = image.size + return NSSize( + width: self.width ?? imageSize.width, + height: self.height ?? imageSize.width + ) + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Messages.g.swift b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Messages.g.swift new file mode 100644 index 000000000000..1e0880d72990 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Messages.g.swift @@ -0,0 +1,309 @@ +// 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. +// Autogenerated from Pigeon (v22.6.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon + +import Foundation + +#if os(iOS) + import Flutter +#elseif os(macOS) + import FlutterMacOS +#else + #error("Unsupported platform.") +#endif + +/// Error class for passing custom error details to Dart side. +final class PigeonError: Error { + let code: String + let message: String? + let details: Any? + + init(code: String, message: String?, details: Any?) { + self.code = code + self.message = message + self.details = details + } + + var localizedDescription: String { + return + "PigeonError(code: \(code), message: \(message ?? ""), details: \(details ?? "")" + } +} + +private func wrapResult(_ result: Any?) -> [Any?] { + return [result] +} + +private func wrapError(_ error: Any) -> [Any?] { + if let pigeonError = error as? PigeonError { + return [ + pigeonError.code, + pigeonError.message, + pigeonError.details, + ] + } + if let flutterError = error as? FlutterError { + return [ + flutterError.code, + flutterError.message, + flutterError.details, + ] + } + return [ + "\(error)", + "\(type(of: error))", + "Stacktrace: \(Thread.callStackSymbols)", + ] +} + +private func isNullish(_ value: Any?) -> Bool { + return value is NSNull || value == nil +} + +private func nilOrValue(_ value: Any?) -> T? { + if value is NSNull { return nil } + return value as! T? +} + +/// The common options between [ImageSelectionOptions], [VideoSelectionOptions] +/// and [MediaSelectionOptions]. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct GeneralOptions { + /// The value `0` means no limit. + var limit: Int64 + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> GeneralOptions? { + let limit = pigeonVar_list[0] as! Int64 + + return GeneralOptions( + limit: limit + ) + } + func toList() -> [Any?] { + return [ + limit + ] + } +} + +/// Represents the maximum size with [width] and [height] dimensions. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct MaxSize { + var width: Double? = nil + var height: Double? = nil + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> MaxSize? { + let width: Double? = nilOrValue(pigeonVar_list[0]) + let height: Double? = nilOrValue(pigeonVar_list[1]) + + return MaxSize( + width: width, + height: height + ) + } + func toList() -> [Any?] { + return [ + width, + height, + ] + } +} + +/// Options for image selection and output. +/// +/// Generated class from Pigeon that represents data sent in messages. +struct ImageSelectionOptions { + /// If set, the max size that the image should be resized to fit in. + var maxSize: MaxSize? = nil + /// The quality of the output image, from 0-100. + /// + /// 100 indicates original quality. + var quality: Int64 + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ImageSelectionOptions? { + let maxSize: MaxSize? = nilOrValue(pigeonVar_list[0]) + let quality = pigeonVar_list[1] as! Int64 + + return ImageSelectionOptions( + maxSize: maxSize, + quality: quality + ) + } + func toList() -> [Any?] { + return [ + maxSize, + quality, + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct MediaSelectionOptions { + var imageSelectionOptions: ImageSelectionOptions + + + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> MediaSelectionOptions? { + let imageSelectionOptions = pigeonVar_list[0] as! ImageSelectionOptions + + return MediaSelectionOptions( + imageSelectionOptions: imageSelectionOptions + ) + } + func toList() -> [Any?] { + return [ + imageSelectionOptions + ] + } +} + +private class messagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + return GeneralOptions.fromList(self.readValue() as! [Any?]) + case 130: + return MaxSize.fromList(self.readValue() as! [Any?]) + case 131: + return ImageSelectionOptions.fromList(self.readValue() as! [Any?]) + case 132: + return MediaSelectionOptions.fromList(self.readValue() as! [Any?]) + default: + return super.readValue(ofType: type) + } + } +} + +private class messagesPigeonCodecWriter: FlutterStandardWriter { + override func writeValue(_ value: Any) { + if let value = value as? GeneralOptions { + super.writeByte(129) + super.writeValue(value.toList()) + } else if let value = value as? MaxSize { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? ImageSelectionOptions { + super.writeByte(131) + super.writeValue(value.toList()) + } else if let value = value as? MediaSelectionOptions { + super.writeByte(132) + super.writeValue(value.toList()) + } else { + super.writeValue(value) + } + } +} + +private class messagesPigeonCodecReaderWriter: FlutterStandardReaderWriter { + override func reader(with data: Data) -> FlutterStandardReader { + return messagesPigeonCodecReader(data: data) + } + + override func writer(with data: NSMutableData) -> FlutterStandardWriter { + return messagesPigeonCodecWriter(data: data) + } +} + +class messagesPigeonCodec: FlutterStandardMessageCodec, @unchecked Sendable { + static let shared = messagesPigeonCodec(readerWriter: messagesPigeonCodecReaderWriter()) +} + + +/// Generated protocol from Pigeon that represents a handler of messages from Flutter. +protocol ImagePickerApi { + func supportsPHPicker() throws -> Bool + func pickImages(options: ImageSelectionOptions, generalOptions: GeneralOptions, completion: @escaping (Result<[String], Error>) -> Void) + /// Currently, multi-video selection is unimplemented. + func pickVideos(generalOptions: GeneralOptions, completion: @escaping (Result<[String], Error>) -> Void) + func pickMedia(options: MediaSelectionOptions, generalOptions: GeneralOptions, completion: @escaping (Result<[String], Error>) -> Void) +} + +/// Generated setup class from Pigeon to handle messages through the `binaryMessenger`. +class ImagePickerApiSetup { + static var codec: FlutterStandardMessageCodec { messagesPigeonCodec.shared } + /// Sets up an instance of `ImagePickerApi` to handle messages through the `binaryMessenger`. + static func setUp(binaryMessenger: FlutterBinaryMessenger, api: ImagePickerApi?, messageChannelSuffix: String = "") { + let channelSuffix = messageChannelSuffix.count > 0 ? ".\(messageChannelSuffix)" : "" + let supportsPHPickerChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.image_picker_macos.ImagePickerApi.supportsPHPicker\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + supportsPHPickerChannel.setMessageHandler { _, reply in + do { + let result = try api.supportsPHPicker() + reply(wrapResult(result)) + } catch { + reply(wrapError(error)) + } + } + } else { + supportsPHPickerChannel.setMessageHandler(nil) + } + let pickImagesChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + pickImagesChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsArg = args[0] as! ImageSelectionOptions + let generalOptionsArg = args[1] as! GeneralOptions + api.pickImages(options: optionsArg, generalOptions: generalOptionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + pickImagesChannel.setMessageHandler(nil) + } + /// Currently, multi-video selection is unimplemented. + let pickVideosChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickVideos\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + pickVideosChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let generalOptionsArg = args[0] as! GeneralOptions + api.pickVideos(generalOptions: generalOptionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + pickVideosChannel.setMessageHandler(nil) + } + let pickMediaChannel = FlutterBasicMessageChannel(name: "dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia\(channelSuffix)", binaryMessenger: binaryMessenger, codec: codec) + if let api = api { + pickMediaChannel.setMessageHandler { message, reply in + let args = message as! [Any?] + let optionsArg = args[0] as! MediaSelectionOptions + let generalOptionsArg = args[1] as! GeneralOptions + api.pickMedia(options: optionsArg, generalOptions: generalOptionsArg) { result in + switch result { + case .success(let res): + reply(wrapResult(res)) + case .failure(let error): + reply(wrapError(error)) + } + } + } + } else { + pickMediaChannel.setMessageHandler(nil) + } + } +} diff --git a/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Resources/PrivacyInfo.xcprivacy b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Resources/PrivacyInfo.xcprivacy new file mode 100644 index 000000000000..c88e30ff9065 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Resources/PrivacyInfo.xcprivacy @@ -0,0 +1,12 @@ + + + + + NSPrivacyTrackingDomains + + NSPrivacyCollectedDataTypes + + NSPrivacyTracking + + + \ No newline at end of file diff --git a/packages/image_picker/image_picker_macos/pigeons/copyright.txt b/packages/image_picker/image_picker_macos/pigeons/copyright.txt new file mode 100644 index 000000000000..1236b63caf3a --- /dev/null +++ b/packages/image_picker/image_picker_macos/pigeons/copyright.txt @@ -0,0 +1,3 @@ +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. diff --git a/packages/image_picker/image_picker_macos/pigeons/messages.dart b/packages/image_picker/image_picker_macos/pigeons/messages.dart new file mode 100644 index 000000000000..d8fc53a6c019 --- /dev/null +++ b/packages/image_picker/image_picker_macos/pigeons/messages.dart @@ -0,0 +1,85 @@ +// 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:pigeon/pigeon.dart'; + +@ConfigurePigeon(PigeonOptions( + dartOut: 'lib/src/messages.g.dart', + dartTestOut: 'test/test_api.g.dart', + swiftOut: + 'macos/image_picker_macos/Sources/image_picker_macos/messages.g.swift', + copyrightHeader: 'pigeons/copyright.txt', +)) + +/// The common options between [ImageSelectionOptions], [VideoSelectionOptions] +/// and [MediaSelectionOptions]. +class GeneralOptions { + GeneralOptions({required this.limit}); + + /// The value `0` means no limit. + int limit; +} + +/// Represents the maximum size with [width] and [height] dimensions. +class MaxSize { + MaxSize(this.width, this.height); + double? width; + double? height; +} + +/// Options for image selection and output. +class ImageSelectionOptions { + ImageSelectionOptions({this.maxSize, required this.quality}); + + /// If set, the max size that the image should be resized to fit in. + MaxSize? maxSize; + + /// The quality of the output image, from 0-100. + /// + /// 100 indicates original quality. + int quality; +} + +// TODO(EchoEllet): Confirm if it's not possible to support maxDurationSeconds with macOS PHPicker +// /// Options for video selection and output. +// class VideoSelectionOptions { +// VideoSelectionOptions(); + +// } + +class MediaSelectionOptions { + MediaSelectionOptions({ + required this.imageSelectionOptions, + }); + + ImageSelectionOptions imageSelectionOptions; +} + +@HostApi(dartHostTestHandler: 'TestHostImagePickerApi') +abstract class ImagePickerApi { + bool supportsPHPicker(); + + // TODO(EchoEllet): Should ImagePickerApi be more similar to image_picker_ios or image_picker_android messages.dart? + // `pickImage()` and `pickMultiImage()` vs `pickImages()` with `limit` and `allowMultiple`. + // Currently it's closer to the image_picker_android messages.dart but without allowMultiple + + // Return file paths + + @async + List pickImages( + ImageSelectionOptions options, + GeneralOptions generalOptions, + ); + + /// Currently, multi-video selection is unimplemented. + @async + List pickVideos( + GeneralOptions generalOptions, + ); + @async + List pickMedia( + MediaSelectionOptions options, + GeneralOptions generalOptions, + ); +} diff --git a/packages/image_picker/image_picker_macos/pubspec.yaml b/packages/image_picker/image_picker_macos/pubspec.yaml index 3b11740e8d86..da5c4a3066fd 100644 --- a/packages/image_picker/image_picker_macos/pubspec.yaml +++ b/packages/image_picker/image_picker_macos/pubspec.yaml @@ -2,7 +2,7 @@ name: image_picker_macos description: macOS platform implementation of image_picker repository: https://github.com/flutter/packages/tree/main/packages/image_picker/image_picker_macos issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22 -version: 0.2.1+1 +version: 0.3.0 environment: sdk: ^3.3.0 @@ -14,6 +14,7 @@ flutter: platforms: macos: dartPluginClass: ImagePickerMacOS + pluginClass: ImagePickerPlugin dependencies: file_selector_macos: ^0.9.1+1 @@ -27,6 +28,7 @@ dev_dependencies: flutter_test: sdk: flutter mockito: ^5.4.4 + pigeon: ^22.6.1 topics: - image-picker diff --git a/packages/image_picker/image_picker_macos/test/image_picker_macos_test.dart b/packages/image_picker/image_picker_macos/test/image_picker_macos_test.dart index 7e94161d4a40..4cad84bb9964 100644 --- a/packages/image_picker/image_picker_macos/test/image_picker_macos_test.dart +++ b/packages/image_picker/image_picker_macos/test/image_picker_macos_test.dart @@ -5,13 +5,15 @@ import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:image_picker_macos/image_picker_macos.dart'; +import 'package:image_picker_macos/src/messages.g.dart'; import 'package:image_picker_platform_interface/image_picker_platform_interface.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'image_picker_macos_test.mocks.dart'; +import 'test_api.g.dart'; -@GenerateMocks([FileSelectorPlatform]) +@GenerateMocks([FileSelectorPlatform, TestHostImagePickerApi]) void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -23,10 +25,12 @@ void main() { late ImagePickerMacOS plugin; late MockFileSelectorPlatform mockFileSelectorPlatform; + late MockTestHostImagePickerApi mockImagePickerApi; setUp(() { plugin = ImagePickerMacOS(); mockFileSelectorPlatform = MockFileSelectorPlatform(); + mockImagePickerApi = MockTestHostImagePickerApi(); when(mockFileSelectorPlatform.openFile( acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) @@ -36,14 +40,78 @@ void main() { acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) .thenAnswer((_) async => List.empty()); + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => false); + + when(mockImagePickerApi.pickImages(any, any)) + .thenAnswer((_) async => []); + + when(mockImagePickerApi.pickVideos(any)) + .thenAnswer((_) async => []); + + when(mockImagePickerApi.pickMedia(any, any)) + .thenAnswer((_) async => []); + ImagePickerMacOS.fileSelector = mockFileSelectorPlatform; + TestHostImagePickerApi.setUp(mockImagePickerApi); }); + void testWithPHPicker({ + required bool enabled, + required void Function() body, + }) { + plugin.useMacOSPHPicker = enabled; + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => enabled); + body(); + } + test('registered instance', () { ImagePickerMacOS.registerWith(); expect(ImagePickerPlatform.instance, isA()); }); + test('defaults to not using macOS PHPicker', () async { + expect(plugin.useMacOSPHPicker, false); + }); + + test( + 'supportsPHPicker delegate to the supportsPHPicker from the platform API', + () async { + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => false); + expect(await plugin.supportsPHPicker(), false); + + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => true); + expect(await plugin.supportsPHPicker(), true); + }, + ); + + test( + 'shouldUsePHPicker returns true when useMacOSPHPicker and supportsPHPicker are true', + () async { + plugin.useMacOSPHPicker = true; + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => true); + expect(await plugin.shouldUsePHPicker(), true); + }); + + test( + 'shouldUsePHPPicker returns false when either useMacOSPHPicker or supportsPHPicker is false', + () async { + plugin.useMacOSPHPicker = false; + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => true); + expect(await plugin.shouldUsePHPicker(), false); + + plugin.useMacOSPHPicker = true; + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => false); + expect(await plugin.shouldUsePHPicker(), false); + }); + + test( + 'shouldUsePHPPicker returns false when both useMacOSPHPicker and supportsPHPicker are false', + () async { + plugin.useMacOSPHPicker = false; + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => false); + expect(await plugin.shouldUsePHPicker(), false); + }); + group('images', () { test('pickImage passes the accepted type groups correctly', () async { await plugin.pickImage(source: ImageSource.gallery); @@ -74,21 +142,46 @@ void main() { }); test('getImageFromSource calls delegate when source is camera', () async { - const String fakePath = '/tmp/foo'; - plugin.cameraDelegate = FakeCameraDelegate(result: XFile(fakePath)); - expect( - (await plugin.getImageFromSource(source: ImageSource.camera))!.path, - fakePath); + Future sharedTest() async { + const String fakePath = '/tmp/foo'; + plugin.cameraDelegate = FakeCameraDelegate(result: XFile(fakePath)); + expect( + (await plugin.getImageFromSource(source: ImageSource.camera))!.path, + fakePath); + } + + // Camera is unsupported on both PHPicker and file_selector, + // ensure always to use the camera delegate + testWithPHPicker(enabled: false, body: sharedTest); + testWithPHPicker(enabled: true, body: sharedTest); }); test( 'getImageFromSource throws StateError when source is camera with no delegate', () async { - await expectLater(plugin.getImageFromSource(source: ImageSource.camera), - throwsStateError); + Future sharedTest() async { + await expectLater(plugin.getImageFromSource(source: ImageSource.camera), + throwsStateError); + } + + // Camera is unsupported on both PHPicker and file_selector, + // ensure always to throw state error + testWithPHPicker(enabled: false, body: sharedTest); + testWithPHPicker(enabled: true, body: sharedTest); }); - test('getMultiImage passes the accepted type groups correctly', () async { + test( + 'getMultiImage delegate to getMultiImageWithOptions', + () async { + // The getMultiImage is soft-deprecated in the platform interface + // and is only implemented for compatibility. Callers should be using getMultiImageWithOptions. + await plugin.getMultiImage(); + verify(plugin.getMultiImageWithOptions()).called(1); + }, + ); + + test('getMultiImageWithOptions passes the accepted type groups correctly', + () async { await plugin.getMultiImage(); final VerificationResult result = verify( @@ -97,9 +190,284 @@ void main() { expect(capturedTypeGroups(result)[0].uniformTypeIdentifiers, ['public.image']); }); + + test( + 'getMultiImageWithOptions uses PHPicker when it is enabled', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMultiImageWithOptions(); + verify(plugin.shouldUsePHPicker()).called(1); + verify(mockImagePickerApi.pickImages(any, any)).called(1); + + verifyNever(mockFileSelectorPlatform.openFiles( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions uses file selector when PHPicker is disabled', + () async { + testWithPHPicker( + enabled: false, + body: () async { + await plugin.getMultiImageWithOptions(); + verify(plugin.shouldUsePHPicker()).called(1); + verifyNever(mockImagePickerApi.pickImages(any, any)); + + verify(mockFileSelectorPlatform.openFiles( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) + .called(1); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions pass 0 as limit to pickImages for PHPicker implementation when unspecified', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMultiImageWithOptions( + // ignore: avoid_redundant_argument_values + options: const MultiImagePickerOptions(limit: null), + ); + verify(mockImagePickerApi.pickImages( + any, + argThat( + predicate( + (GeneralOptions options) => options.limit == 0), + ), + )); + }, + ); + }, + ); + + test( + 'getImageFromSource uses PHPicker when it is enabled', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getImageFromSource(source: ImageSource.gallery); + verify(plugin.shouldUsePHPicker()).called(1); + verify(mockImagePickerApi.pickImages(any, any)).called(1); + + verifyNever(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))); + }, + ); + }, + ); + + test( + 'getImageFromSource uses file selector when PHPicker is disabled', + () async { + testWithPHPicker( + enabled: false, + body: () async { + await plugin.getImageFromSource(source: ImageSource.gallery); + verify(plugin.shouldUsePHPicker()).called(1); + verifyNever(mockImagePickerApi.pickImages(any, any)); + + verify(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) + .called(1); + }, + ); + }, + ); + + test( + 'getImageFromSource pass 1 as limit to pickImages for PHPicker implementation', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getImageFromSource(source: ImageSource.gallery); + + verify(mockImagePickerApi.pickImages( + any, + argThat( + predicate( + (GeneralOptions options) => options.limit == 1), + ), + )).called(1); + }, + ); + }, + ); + + test( + 'getImageFromSource uses 100 as image quality if not provided', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getImageFromSource( + source: ImageSource.gallery, + // ignore: avoid_redundant_argument_values + options: const ImagePickerOptions(imageQuality: null), + ); + + verify(mockImagePickerApi.pickImages( + argThat( + predicate( + (ImageSelectionOptions options) => options.quality == 100), + ), + any, + )).called(1); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions uses 100 as image quality if not provided', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMultiImageWithOptions( + // ignore: avoid_redundant_argument_values + options: const MultiImagePickerOptions( + // ignore: avoid_redundant_argument_values + imageOptions: ImageOptions(imageQuality: null), + ), + ); + + verify(mockImagePickerApi.pickImages( + argThat( + predicate( + (ImageSelectionOptions options) => options.quality == 100), + ), + any, + )).called(1); + }, + ); + }, + ); + + test( + 'getImageFromSource return the file from the platform API for PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + final List filePaths = ['path/to/file']; + when(mockImagePickerApi.pickImages( + any, + any, + )).thenAnswer((_) async { + return filePaths; + }); + expect( + (await plugin.pickImage(source: ImageSource.gallery))?.path, + filePaths.first, + ); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions return the file from the platform API for PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + final List filePaths = [ + '/foo/bar/image.png', + '/dev/flutter/plugins/video.mp4', + 'path/to/file' + ]; + when(mockImagePickerApi.pickImages( + any, + any, + )).thenAnswer((_) async { + return filePaths; + }); + expect( + (await plugin.getMultiImageWithOptions()) + .map((XFile file) => file.path), + filePaths, + ); + }, + ); + }, + ); + + test( + 'getImageFromSource passes the arguments correctly to the platform API for the PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + const ImagePickerOptions imageOptions = ImagePickerOptions( + imageQuality: 50, + maxHeight: 40, + maxWidth: 30, + ); + await plugin.getImageFromSource( + source: ImageSource.gallery, options: imageOptions); + verify(mockImagePickerApi.pickImages( + argThat(predicate( + (ImageSelectionOptions options) => + options.maxSize?.width == imageOptions.maxWidth && + options.maxSize?.height == imageOptions.maxHeight && + options.quality == imageOptions.imageQuality, + )), + argThat(predicate( + (GeneralOptions options) => options.limit == 1, + )), + )); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions passes the arguments correctly to the platform API for the PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + const MultiImagePickerOptions multiImageOptions = + MultiImagePickerOptions( + imageOptions: + ImageOptions(imageQuality: 50, maxHeight: 40, maxWidth: 30), + limit: 50, + ); + await plugin.getMultiImageWithOptions(options: multiImageOptions); + + verify(mockImagePickerApi.pickImages( + argThat(predicate( + (ImageSelectionOptions options) => + options.maxSize?.width == + multiImageOptions.imageOptions.maxWidth && + options.maxSize?.height == + multiImageOptions.imageOptions.maxHeight && + options.quality == + multiImageOptions.imageOptions.imageQuality, + )), + argThat(predicate( + (GeneralOptions options) => + options.limit == multiImageOptions.limit, + )), + )); + }, + ); + }, + ); }); group('videos', () { + // TODO(EchoEllet): (Nit) Should this uses getVideo() instead of the soft-deprecated pickVideo() for consistency? test('pickVideo passes the accepted type groups correctly', () async { await plugin.pickVideo(source: ImageSource.gallery); @@ -119,17 +487,107 @@ void main() { }); test('getVideo calls delegate when source is camera', () async { - const String fakePath = '/tmp/foo'; - plugin.cameraDelegate = FakeCameraDelegate(result: XFile(fakePath)); - expect( - (await plugin.getVideo(source: ImageSource.camera))!.path, fakePath); + Future sharedTest() async { + const String fakePath = '/tmp/foo'; + plugin.cameraDelegate = FakeCameraDelegate(result: XFile(fakePath)); + expect((await plugin.getVideo(source: ImageSource.camera))!.path, + fakePath); + } + + // Camera is unsupported on both PHPicker and file_selector, + // ensure always to use the camera delegate + testWithPHPicker(enabled: false, body: sharedTest); + testWithPHPicker(enabled: true, body: sharedTest); }); test('getVideo throws StateError when source is camera with no delegate', () async { - await expectLater( - plugin.getVideo(source: ImageSource.camera), throwsStateError); + Future sharedTest() async { + await expectLater( + plugin.getVideo(source: ImageSource.camera), throwsStateError); + } + + // Camera is unsupported on both PHPicker and file_selector, + // ensure always to throw state error + testWithPHPicker(enabled: false, body: sharedTest); + testWithPHPicker(enabled: true, body: sharedTest); }); + + test( + 'getVideo uses PHPicker when it is enabled', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getVideo(source: ImageSource.gallery); + verify(plugin.shouldUsePHPicker()).called(1); + verify(mockImagePickerApi.pickVideos(any)).called(1); + + verifyNever(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))); + }, + ); + }, + ); + + // TODO(EchoEllet): Improve the test names for this and all related in this file + test( + 'getVideo uses file selector when PHPicker is disabled', + () async { + testWithPHPicker( + enabled: false, + body: () async { + await plugin.getVideo(source: ImageSource.gallery); + verify(plugin.shouldUsePHPicker()).called(1); + verifyNever(mockImagePickerApi.pickVideos(any)); + + verify(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) + .called(1); + }, + ); + }, + ); + + test( + 'getVideo pass 1 as limit to pickVideos for PHPicker implementation', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getVideo(source: ImageSource.gallery); + + verify(mockImagePickerApi.pickVideos( + argThat( + predicate( + (GeneralOptions options) => options.limit == 1), + ), + )).called(1); + }, + ); + }, + ); + + test( + 'getVideo return the file from the platform API for PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + final List filePaths = ['path/to/file']; + when(mockImagePickerApi.pickVideos( + any, + )).thenAnswer((_) async { + return filePaths; + }); + expect( + (await plugin.getVideo(source: ImageSource.gallery))?.path, + filePaths.first, + ); + }, + ); + }, + ); }); group('media', () { @@ -162,6 +620,186 @@ void main() { ), []); }); + + test( + 'getMedia uses file selector when PHPicker is disabled', + () async { + testWithPHPicker( + enabled: false, + body: () async { + Future sharedTest({required bool allowMultiple}) async { + await plugin.getMedia( + options: MediaOptions(allowMultiple: allowMultiple)); + verify(plugin.shouldUsePHPicker()).called(1); + verifyNever(mockImagePickerApi.pickMedia(any, any)); + + if (allowMultiple) { + verify(mockFileSelectorPlatform.openFiles( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) + .called(1); + } else { + verify(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) + .called(1); + } + } + + await sharedTest(allowMultiple: true); + await sharedTest(allowMultiple: false); + }, + ); + }, + ); + + test( + 'getMedia uses PHPicker when it is enabled', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMedia( + options: const MediaOptions(allowMultiple: false), + ); + verify(plugin.shouldUsePHPicker()).called(1); + verify(mockImagePickerApi.pickMedia(any, any)).called(1); + + verifyNever(mockFileSelectorPlatform.openFile( + acceptedTypeGroups: anyNamed('acceptedTypeGroups'))); + }, + ); + }, + ); + + test( + 'getMultiImageWithOptions pass 0 as limit to pickImages when unspecified ' + 'and 1 if allowMultiple is false for PHPicker implementation', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMedia( + options: const MediaOptions( + allowMultiple: true, + // ignore: avoid_redundant_argument_values + limit: null, + ), + ); + verify(mockImagePickerApi.pickMedia( + any, + argThat( + predicate( + (GeneralOptions options) => options.limit == 0), + ), + )); + + await plugin.getMedia( + options: const MediaOptions( + allowMultiple: false, + // ignore: avoid_redundant_argument_values + limit: null, + ), + ); + verify(mockImagePickerApi.pickMedia( + any, + argThat( + predicate( + (GeneralOptions options) => options.limit == 1), + ), + )); + }, + ); + }, + ); + + test( + 'getMedia return the files from the platform API for PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + final List filePaths = [ + '/foo/bar/image.png', + '/dev/flutter/plugins/video.mp4', + 'path/to/file' + ]; + when(mockImagePickerApi.pickMedia( + any, + any, + )).thenAnswer((_) async { + return filePaths; + }); + expect( + (await plugin.getMedia( + options: const MediaOptions(allowMultiple: true))) + .map((XFile file) => file.path), + filePaths, + ); + }, + ); + }, + ); + + test( + 'getMedia uses 100 as image quality if not provided', + () async { + testWithPHPicker( + enabled: true, + body: () async { + await plugin.getMedia( + options: const MediaOptions( + allowMultiple: true, + // ignore: avoid_redundant_argument_values + imageOptions: ImageOptions(imageQuality: null), + ), + ); + + verify(mockImagePickerApi.pickMedia( + argThat( + predicate( + (MediaSelectionOptions options) => + options.imageSelectionOptions.quality == 100), + ), + any, + )).called(1); + }, + ); + }, + ); + + test( + 'getMedia passes the arguments correctly to the platform API for the PHPicker implementation', + () { + testWithPHPicker( + enabled: true, + body: () async { + const MediaOptions mediaOptions = MediaOptions( + allowMultiple: true, + imageOptions: ImageOptions( + maxWidth: 500, + maxHeight: 300, + imageQuality: 80, + ), + limit: 10, + ); + await plugin.getMedia(options: mediaOptions); + verify(mockImagePickerApi.pickMedia( + argThat(predicate( + (MediaSelectionOptions options) => + options.imageSelectionOptions.maxSize?.width == + mediaOptions.imageOptions.maxWidth && + options.imageSelectionOptions.maxSize?.height == + mediaOptions.imageOptions.maxHeight && + options.imageSelectionOptions.quality == + mediaOptions.imageOptions.imageQuality, + )), + argThat(predicate( + (GeneralOptions options) => options.limit == mediaOptions.limit, + )), + )); + }, + ); + }, + ); }); } diff --git a/packages/image_picker/image_picker_macos/test/image_picker_macos_test.mocks.dart b/packages/image_picker/image_picker_macos/test/image_picker_macos_test.mocks.dart index 0887befdb0bd..71019b86c8c7 100644 --- a/packages/image_picker/image_picker_macos/test/image_picker_macos_test.mocks.dart +++ b/packages/image_picker/image_picker_macos/test/image_picker_macos_test.mocks.dart @@ -7,8 +7,11 @@ import 'dart:async' as _i3; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart' as _i2; +import 'package:image_picker_macos/src/messages.g.dart' as _i5; import 'package:mockito/mockito.dart' as _i1; +import 'test_api.g.dart' as _i4; + // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values // ignore_for_file: avoid_setters_without_getters @@ -141,3 +144,64 @@ class MockFileSelectorPlatform extends _i1.Mock returnValue: _i3.Future>.value([]), ) as _i3.Future>); } + +/// A class which mocks [TestHostImagePickerApi]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockTestHostImagePickerApi extends _i1.Mock + implements _i4.TestHostImagePickerApi { + MockTestHostImagePickerApi() { + _i1.throwOnMissingStub(this); + } + + @override + bool supportsPHPicker() => (super.noSuchMethod( + Invocation.method( + #supportsPHPicker, + [], + ), + returnValue: false, + ) as bool); + + @override + _i3.Future> pickImages( + _i5.ImageSelectionOptions? options, + _i5.GeneralOptions? generalOptions, + ) => + (super.noSuchMethod( + Invocation.method( + #pickImages, + [ + options, + generalOptions, + ], + ), + returnValue: _i3.Future>.value([]), + ) as _i3.Future>); + + @override + _i3.Future> pickVideos(_i5.GeneralOptions? generalOptions) => + (super.noSuchMethod( + Invocation.method( + #pickVideos, + [generalOptions], + ), + returnValue: _i3.Future>.value([]), + ) as _i3.Future>); + + @override + _i3.Future> pickMedia( + _i5.MediaSelectionOptions? options, + _i5.GeneralOptions? generalOptions, + ) => + (super.noSuchMethod( + Invocation.method( + #pickMedia, + [ + options, + generalOptions, + ], + ), + returnValue: _i3.Future>.value([]), + ) as _i3.Future>); +} diff --git a/packages/image_picker/image_picker_macos/test/test_api.g.dart b/packages/image_picker/image_picker_macos/test/test_api.g.dart new file mode 100644 index 000000000000..e1f8eeba93d1 --- /dev/null +++ b/packages/image_picker/image_picker_macos/test/test_api.g.dart @@ -0,0 +1,174 @@ +// 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. +// Autogenerated from Pigeon (v22.6.0), do not edit directly. +// See also: https://pub.dev/packages/pigeon +// ignore_for_file: public_member_api_docs, non_constant_identifier_names, avoid_as, unused_import, unnecessary_parenthesis, unnecessary_import, no_leading_underscores_for_local_identifiers +// ignore_for_file: avoid_relative_lib_imports +import 'dart:async'; +import 'dart:typed_data' show Float64List, Int32List, Int64List, Uint8List; +import 'package:flutter/foundation.dart' show ReadBuffer, WriteBuffer; +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'package:image_picker_macos/src/messages.g.dart'; + + +class _PigeonCodec extends StandardMessageCodec { + const _PigeonCodec(); + @override + void writeValue(WriteBuffer buffer, Object? value) { + if (value is int) { + buffer.putUint8(4); + buffer.putInt64(value); + } else if (value is GeneralOptions) { + buffer.putUint8(129); + writeValue(buffer, value.encode()); + } else if (value is MaxSize) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is ImageSelectionOptions) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is MediaSelectionOptions) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + return GeneralOptions.decode(readValue(buffer)!); + case 130: + return MaxSize.decode(readValue(buffer)!); + case 131: + return ImageSelectionOptions.decode(readValue(buffer)!); + case 132: + return MediaSelectionOptions.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +abstract class TestHostImagePickerApi { + static TestDefaultBinaryMessengerBinding? get _testBinaryMessengerBinding => TestDefaultBinaryMessengerBinding.instance; + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + bool supportsPHPicker(); + + Future> pickImages(ImageSelectionOptions options, GeneralOptions generalOptions); + + /// Currently, multi-video selection is unimplemented. + Future> pickVideos(GeneralOptions generalOptions); + + Future> pickMedia(MediaSelectionOptions options, GeneralOptions generalOptions); + + static void setUp(TestHostImagePickerApi? api, {BinaryMessenger? binaryMessenger, String messageChannelSuffix = '',}) { + messageChannelSuffix = messageChannelSuffix.isNotEmpty ? '.$messageChannelSuffix' : ''; + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.supportsPHPicker$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, (Object? message) async { + try { + final bool output = api.supportsPHPicker(); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages was null.'); + final List args = (message as List?)!; + final ImageSelectionOptions? arg_options = (args[0] as ImageSelectionOptions?); + assert(arg_options != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages was null, expected non-null ImageSelectionOptions.'); + final GeneralOptions? arg_generalOptions = (args[1] as GeneralOptions?); + assert(arg_generalOptions != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickImages was null, expected non-null GeneralOptions.'); + try { + final List output = await api.pickImages(arg_options!, arg_generalOptions!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickVideos$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickVideos was null.'); + final List args = (message as List?)!; + final GeneralOptions? arg_generalOptions = (args[0] as GeneralOptions?); + assert(arg_generalOptions != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickVideos was null, expected non-null GeneralOptions.'); + try { + final List output = await api.pickVideos(arg_generalOptions!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + { + final BasicMessageChannel pigeonVar_channel = BasicMessageChannel( + 'dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia$messageChannelSuffix', pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, null); + } else { + _testBinaryMessengerBinding!.defaultBinaryMessenger.setMockDecodedMessageHandler(pigeonVar_channel, (Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia was null.'); + final List args = (message as List?)!; + final MediaSelectionOptions? arg_options = (args[0] as MediaSelectionOptions?); + assert(arg_options != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia was null, expected non-null MediaSelectionOptions.'); + final GeneralOptions? arg_generalOptions = (args[1] as GeneralOptions?); + assert(arg_generalOptions != null, + 'Argument for dev.flutter.pigeon.image_picker_macos.ImagePickerApi.pickMedia was null, expected non-null GeneralOptions.'); + try { + final List output = await api.pickMedia(arg_options!, arg_generalOptions!); + return [output]; + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse(error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } + } +}