diff --git a/packages/image_picker/image_picker/CHANGELOG.md b/packages/image_picker/image_picker/CHANGELOG.md index ee08d0510f3c..3180f9b1e763 100644 --- a/packages/image_picker/image_picker/CHANGELOG.md +++ b/packages/image_picker/image_picker/CHANGELOG.md @@ -1,6 +1,7 @@ -## NEXT +## 1.1.3 * Updates minimum supported SDK version to Flutter 3.22/Dart 3.4. +* Updates README to include a reference to the macOS PHPicker feature. ## 1.1.2 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 d2f49724c883..5830715fed5d 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.4.0 diff --git a/packages/image_picker/image_picker_macos/CHANGELOG.md b/packages/image_picker/image_picker_macos/CHANGELOG.md index f1b3bd3f1597..0052b5477306 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.22/Dart 3.4. +* 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..9faa8ca266d9 100644 --- a/packages/image_picker/image_picker_macos/README.md +++ b/packages/image_picker/image_picker_macos/README.md @@ -2,15 +2,44 @@ 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 +54,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..d34915ca4992 --- /dev/null +++ b/packages/image_picker/image_picker_macos/example/integration_test/image_picker_test.dart @@ -0,0 +1,46 @@ +// 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_test/flutter_test.dart'; +import 'package:image_picker_macos/image_picker_macos.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'); + }, + ); + }); +} 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 69e3b8961bfa..06bf3a683ab6 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 */; }; 78A318202AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage in Frameworks */ = {isa = PBXBuildFile; productRef = 78A3181F2AECB46A00862997 /* FlutterGeneratedPluginSwiftPackage */; }; + 7AA9FB252D27461200BBBB29 /* ImageTestUtils.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7AA9FB242D27461000BBBB29 /* ImageTestUtils.swift */; }; + 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 */; }; BBE2B8C47A32673657F9E2DC /* Pods_Runner.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 7B40E0F19CFF2111C7C9F07D /* Pods_Runner.framework */; }; /* End PBXBuildFile section */ @@ -38,6 +43,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 */ @@ -69,6 +88,13 @@ 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 = ""; }; 450A6A13EFEFF8F54A1685E1 /* 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 = ""; }; + 7AA9FB242D27461000BBBB29 /* ImageTestUtils.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageTestUtils.swift; 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 = ""; }; 7B40E0F19CFF2111C7C9F07D /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 9740EEB21CF90195004384FC /* Debug.xcconfig */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.xcconfig; path = Debug.xcconfig; sourceTree = ""; }; @@ -86,6 +112,20 @@ ); 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 */ @@ -105,6 +145,8 @@ children = ( 33FAB671232836740065AC1E /* Runner */, 33CEB47122A05771004F2AC0 /* Flutter */, + 7ABC95822CBD9D810004CBA6 /* RunnerTests */, + 7ABC95882CBD9D8A0004CBA6 /* RunnerUITests */, 33CC10EE2044A3C60003C045 /* Products */, D73912EC22F37F3D000D13A0 /* Frameworks */, E0DBDFAB26ECD71761DCC07A /* Pods */, @@ -115,6 +157,8 @@ isa = PBXGroup; children = ( 33CC10ED2044A3C60003C045 /* example.app */, + 7ABC952B2CB979800004CBA6 /* RunnerTests.xctest */, + 7ABC95492CBAF9680004CBA6 /* RunnerUITests.xctest */, ); name = Products; sourceTree = ""; @@ -154,6 +198,25 @@ path = Runner; sourceTree = ""; }; + 7ABC95822CBD9D810004CBA6 /* RunnerTests */ = { + isa = PBXGroup; + children = ( + 7AA9FB242D27461000BBBB29 /* ImageTestUtils.swift */, + 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 = ( @@ -169,7 +232,6 @@ EA63AF049335E53A5E609A89 /* Pods-Runner.release.xcconfig */, AFE3DE60B5E9D50D0C5C0524 /* Pods-Runner.profile.xcconfig */, ); - name = Pods; path = Pods; sourceTree = ""; }; @@ -200,13 +262,49 @@ 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 = { @@ -224,6 +322,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" */; @@ -236,7 +343,7 @@ ); mainGroup = 33CC10E42044A3C60003C045; packageReferences = ( - 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "FlutterGeneratedPluginSwiftPackage" */, + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */, ); productRefGroup = 33CC10EE2044A3C60003C045 /* Products */; projectDirPath = ""; @@ -244,6 +351,8 @@ targets = ( 33CC10EC2044A3C60003C045 /* Runner */, 33CC111A2044C6BA0003C045 /* Flutter Assemble */, + 7ABC952A2CB979800004CBA6 /* RunnerTests */, + 7ABC95482CBAF9680004CBA6 /* RunnerUITests */, ); }; /* End PBXProject section */ @@ -258,6 +367,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 */ @@ -334,6 +457,25 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + 7ABC95272CB979800004CBA6 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 7ABC95832CBD9D810004CBA6 /* ImageCompressTests.swift in Sources */, + 7ABC95842CBD9D810004CBA6 /* ImageResizeTests.swift in Sources */, + 7ABC95852CBD9D810004CBA6 /* RunnerTests.swift in Sources */, + 7AA9FB252D27461200BBBB29 /* ImageTestUtils.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 */ @@ -342,6 +484,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 */ @@ -584,6 +736,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 */ @@ -617,10 +900,30 @@ 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 "FlutterGeneratedPluginSwiftPackage" */ = { + 781AD8BC2B33823900A9FFBB /* XCLocalSwiftPackageReference "Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage" */ = { isa = XCLocalSwiftPackageReference; relativePath = Flutter/ephemeral/Packages/FlutterGeneratedPluginSwiftPackage; }; 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 8b5a3a2814eb..1d4cc11b9312 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 @@ -55,6 +55,28 @@ + + + + + + + + NSImage { + let image = NSImage(size: size) + image.lockFocus() + NSColor.white.set() + NSBezierPath(rect: NSRect(origin: .zero, size: size)).fill() + image.unlockFocus() + return image +} 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 66435a80756b..c9b117318cf9 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..41951c7bfe03 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 @@ -5,8 +5,11 @@ import 'package:file_selector_macos/file_selector_macos.dart'; import 'package:file_selector_platform_interface/file_selector_platform_interface.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/services.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 +18,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 +31,39 @@ 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; + + /// Return `true` if the current macOS version supports [useMacOSPHPicker]. + /// + /// The [PHPicker](https://developer.apple.com/documentation/photokit/phpickerviewcontroller) + /// is **supported on macOS 13.0+** + 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 +123,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 +138,20 @@ 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), + )) + .getSuccessOrThrow() + .filePaths + .firstOrNull; + if (imagePath == null) { + return null; + } + + return XFile(imagePath); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.image']); final XFile? file = await fileSelector @@ -130,6 +182,18 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { preferredCameraDevice: preferredCameraDevice, maxDuration: maxDuration); case ImageSource.gallery: + if (await shouldUsePHPicker()) { + final String? videoPath = + (await _hostApi.pickVideos(GeneralOptions(limit: 1))) + .getSuccessOrThrow() + .filePaths + .firstOrNull; + if (videoPath == null) { + return null; + } + + return XFile(videoPath); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.movie']); final XFile? file = await fileSelector @@ -141,18 +205,43 @@ 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, + ), + )) + .getSuccessOrThrow() + .filePaths; + return images.map((String imagePath) => XFile(imagePath)).toList(); + } const XTypeGroup typeGroup = XTypeGroup(uniformTypeIdentifiers: ['public.image']); final List files = await fileSelector @@ -160,11 +249,43 @@ 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, + ) { + final int? imageQuality = imageOptions.imageQuality; + if (imageQuality != null && imageQuality < 0) { + throw ArgumentError.value( + imageQuality, 'imageQuality', 'quality cannot be negative'); + } + + return ImageSelectionOptions( + quality: 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), + ), + )) + .getSuccessOrThrow() + .filePaths; + return images.map((String mediaPath) => XFile(mediaPath)).toList(); + } const XTypeGroup typeGroup = XTypeGroup( label: 'images and videos', extensions: ['public.image', 'public.movie']); @@ -184,3 +305,47 @@ class ImagePickerMacOS extends CameraDelegatingImagePickerPlatform { return files; } } + +extension _ImagePickerResultExt on ImagePickerResult { + /// Returns the result as an [ImagePickerSuccessResult], or throws a [PlatformException] + /// if the result is an [ImagePickerErrorResult]. + ImagePickerSuccessResult getSuccessOrThrow() { + final ImagePickerResult result = this; + return switch (result) { + ImagePickerSuccessResult() => result, + ImagePickerErrorResult() => () { + final String errorMessage = switch (result.error) { + ImagePickerError.phpickerUnsupported => + 'PHPicker is only supported on macOS 13.0 or newer.', + ImagePickerError.windowNotFound => + 'No active window to present the picker.', + ImagePickerError.invalidImageSelection => + 'One of the selected items is not an image.', + ImagePickerError.invalidVideoSelection => + 'One of the selected items is not a video.', + ImagePickerError.imageLoadFailed => + 'An error occurred while loading the image.', + ImagePickerError.videoLoadFailed => + 'An error occurred while loading the video.', + ImagePickerError.imageConversionFailed => + 'Failed to convert the NSImage to TIFF data.', + ImagePickerError.imageSaveFailed => + 'Error saving the NSImage data to a file.', + ImagePickerError.imageCompressionFailed => + 'Error while compressing the Data of the NSImage.', + ImagePickerError.multiVideoSelectionUnsupported => + 'The multi-video selection is not supported.', + }; + // TODO(EchoEllet): Replace PlatformException with a plugin-specific exception. + // This is currently implemented to maintain compatibility with the existing behavior + // of other implementations of `image_picker`. For more details, refer to: + // https://github.com/flutter/flutter/blob/master/docs/ecosystem/contributing/README.md#platform-exception-handling + throw PlatformException( + code: result.error.name, + message: errorMessage, + details: result.platformErrorMessage, + ); + }(), + }; + } +} 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..87f38080f2f3 --- /dev/null +++ b/packages/image_picker/image_picker_macos/lib/src/messages.g.dart @@ -0,0 +1,418 @@ +// 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.7.2), 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]; +} + +/// Possible error conditions for [ImagePickerApi] calls. +enum ImagePickerError { + /// The current macOS version doesn't support [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// which is supported on macOS 13+. + phpickerUnsupported, + + /// Could not show the picker due to the missing window. + windowNotFound, + + /// When a `PHPickerResult` can't load `NSImage`. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only images. + invalidImageSelection, + + /// When a `PHPickerResult` is not a video. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only videos. + invalidVideoSelection, + + /// Could not load the image object as `NSImage`. + imageLoadFailed, + + /// Could not load the video data representation. + videoLoadFailed, + + /// The image tiff representation could not be loaded from the `NSImage`. + imageConversionFailed, + + /// The loaded `Data` from the `NSImage` could not be written as a file. + imageSaveFailed, + + /// The image could not be compressed or the `NSImage` could not be created + /// from the compressed `Data`. + imageCompressionFailed, + + /// The multi-video selection is not supported as it's not supported in + /// the app-facing package (`pickVideos` is missing). + /// The multi-video selection is supported when using `pickMedia` instead. + multiVideoSelectionUnsupported, +} + +/// 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, + ); + } +} + +sealed class ImagePickerResult {} + +class ImagePickerSuccessResult extends ImagePickerResult { + ImagePickerSuccessResult({ + required this.filePaths, + }); + + /// The temporary file paths as a result of picking the images and/or videos. + List filePaths; + + Object encode() { + return [ + filePaths, + ]; + } + + static ImagePickerSuccessResult decode(Object result) { + result as List; + return ImagePickerSuccessResult( + filePaths: (result[0] as List?)!.cast(), + ); + } +} + +class ImagePickerErrorResult extends ImagePickerResult { + ImagePickerErrorResult({ + required this.error, + this.platformErrorMessage, + }); + + /// Potential error conditions for [ImagePickerApi] calls. + ImagePickerError error; + + /// Additional error message from the platform side. + String? platformErrorMessage; + + Object encode() { + return [ + error, + platformErrorMessage, + ]; + } + + static ImagePickerErrorResult decode(Object result) { + result as List; + return ImagePickerErrorResult( + error: result[0]! as ImagePickerError, + platformErrorMessage: result[1] as String?, + ); + } +} + +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 ImagePickerError) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is GeneralOptions) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is MaxSize) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is ImageSelectionOptions) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is MediaSelectionOptions) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is ImagePickerSuccessResult) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is ImagePickerErrorResult) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : ImagePickerError.values[value]; + case 130: + return GeneralOptions.decode(readValue(buffer)!); + case 131: + return MaxSize.decode(readValue(buffer)!); + case 132: + return ImageSelectionOptions.decode(readValue(buffer)!); + case 133: + return MediaSelectionOptions.decode(readValue(buffer)!); + case 134: + return ImagePickerSuccessResult.decode(readValue(buffer)!); + case 135: + return ImagePickerErrorResult.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; + + /// Returns whether [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// is supported on the current macOS version. + 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 ImagePickerResult?)!; + } + } + + /// 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 ImagePickerResult?)!; + } + } + + 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 ImagePickerResult?)!; + } + } +} 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..9371c6dade36 --- /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.14' + 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..2d0f14c22dc5 --- /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.14") + ], + 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..d4e2e76f079e --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImageCompress.swift @@ -0,0 +1,68 @@ +// 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 != 100 +} + +enum ImageCompressingError: Error { + /// Failed to convert the `NSImage` to TIFF data. + case conversionFailed + /// Failed to compress the image. + case compressionFailed + /// Failed to create `NSImage` from the compressed data. + case creationFailed +} + +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 { + guard let tiffData = self.tiffRepresentation, + let bitmapRep = NSBitmapImageRep(data: tiffData) + else { + throw ImageCompressingError.conversionFailed + } + + // 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 ImageCompressingError.compressionFailed + } + + guard let compressedImage = NSImage(data: compressedData) else { + throw ImageCompressingError.creationFailed + } + + 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 { + guard let quality = quality else { + return self + } + if !shouldCompressImage(quality: quality) { + 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..0ec4ec700559 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/ImagePickerImpl.swift @@ -0,0 +1,479 @@ +// 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) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion(.success(ImagePickerErrorResult(error: .phpickerUnsupported))) + 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(.success(ImagePickerErrorResult(error: .windowNotFound))) + }) + } + + func pickVideos( + generalOptions: GeneralOptions, + completion: @escaping (Result) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion(.success(ImagePickerErrorResult(error: .phpickerUnsupported))) + return + } + + if generalOptions.limit != nil && generalOptions.limit != 1 { + completion(.success(ImagePickerErrorResult(error: .multiVideoSelectionUnsupported))) + 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(.success(ImagePickerErrorResult(error: .windowNotFound))) + }) + } + + func pickMedia( + options: MediaSelectionOptions, generalOptions: GeneralOptions, + completion: @escaping (Result) -> Void + ) { + guard #available(macOS 13.0, *) else { + completion(.success(ImagePickerErrorResult(error: .phpickerUnsupported))) + 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(.success(ImagePickerErrorResult(error: .windowNotFound))) + }) + } + + @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) -> Void) + private let options: ImageSelectionOptions + + init( + completion: @escaping ((Result) -> 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(ImagePickerSuccessResult(filePaths: []))) + return + } + + var savedFilePaths: [String] = [] + + Task { + for result in results { + let itemProvider = result.itemProvider + guard itemProvider.canLoadObject(ofClass: NSImage.self) else { + completion(.success(ImagePickerErrorResult(error: .invalidImageSelection))) + return + } + + guard + let tempImagePath = await PickImageHandler( + completion: completion, options: options + ).processAndSave(itemProvider: itemProvider) + else { return } + savedFilePaths.append(tempImagePath) + } + completion(.success(ImagePickerSuccessResult(filePaths: savedFilePaths))) + } + } +} + +// Currently, multi-video selection is unimplemented. +class PickVideosDelegate: PHPickerViewControllerDelegate { + private let completion: ((Result) -> Void) + + init(completion: @escaping ((Result) -> 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(ImagePickerSuccessResult(filePaths: []))) + return + } + + let canLoadVideo = itemProvider.hasItemConformingToTypeIdentifier(UTType.movie.identifier) + if !canLoadVideo { + completion(.success(ImagePickerErrorResult(error: .invalidVideoSelection))) + return + } + + Task { + guard + let tempVideoPath = await PickVideoHandler(completion: completion) + .processAndSave(itemProvider: itemProvider) + else { return } + + completion(.success(ImagePickerSuccessResult(filePaths: [tempVideoPath]))) + } + + } +} + +class PickMediaDelegate: PHPickerViewControllerDelegate { + private let completion: ((Result) -> Void) + private let options: MediaSelectionOptions + + init( + completion: @escaping (Result) -> 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(ImagePickerSuccessResult(filePaths: []))) + 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(ImagePickerSuccessResult(filePaths: 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 = quality != nil && shouldCompressImage(quality: quality!) + // TODO(EchoEllet): The picked image can be JPEG even if it can represented as a PNG, should we always store as PNG in case quality is 100 but the image itself is JPEG or other type? + 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 { + 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) -> Void) + let options: ImageSelectionOptions + + init( + completion: @escaping (Result) -> 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( + .success( + ImagePickerErrorResult( + error: .imageLoadFailed, platformErrorMessage: error.localizedDescription))) + 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(.success(ImagePickerErrorResult(error: .imageConversionFailed))) + return nil + } + + let filePath = generateTempImageFilePath(imageFileType: imageFileType) + + do { + try imageData.write(to: filePath) + return filePath.pathString() + } catch { + completion( + .success( + ImagePickerErrorResult( + error: .imageSaveFailed, platformErrorMessage: error.localizedDescription))) + 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 resizedOrOriginalImage = image.resizedOrOriginal(maxSize: options.maxSize) + let compressedOrOriginalImage = try resizedOrOriginalImage.compressedOrOriginal( + quality: options.quality) + return compressedOrOriginalImage + } catch ImageCompressingError.conversionFailed { + completion( + .success( + ImagePickerErrorResult( + error: .imageConversionFailed))) + return nil + } catch { + completion( + .success( + ImagePickerErrorResult( + error: .imageCompressionFailed, platformErrorMessage: error.localizedDescription))) + return nil + } + } +} + +/// Shared image handling between `PickVideosDelegate` and `PickMediaDelegate`. +class PickVideoHandler { + let completion: ((Result) -> Void) + + init(completion: @escaping (Result) -> 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 = try await itemProvider.loadDataRepresentation(for: videoType) + try videoData.write(to: tempVideoUrl) + + let tempVideoPath = tempVideoUrl.pathString() + + return tempVideoPath + } catch { + completion( + .success( + ImagePickerErrorResult( + error: .videoLoadFailed, platformErrorMessage: error.localizedDescription))) + 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..6dc2e0f9aaf5 --- /dev/null +++ b/packages/image_picker/image_picker_macos/macos/image_picker_macos/Sources/image_picker_macos/Messages.g.swift @@ -0,0 +1,420 @@ +// 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.7.2), 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? +} + +/// Possible error conditions for [ImagePickerApi] calls. +enum ImagePickerError: Int { + /// The current macOS version doesn't support [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// which is supported on macOS 13+. + case phpickerUnsupported = 0 + /// Could not show the picker due to the missing window. + case windowNotFound = 1 + /// When a `PHPickerResult` can't load `NSImage`. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only images. + case invalidImageSelection = 2 + /// When a `PHPickerResult` is not a video. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only videos. + case invalidVideoSelection = 3 + /// Could not load the image object as `NSImage`. + case imageLoadFailed = 4 + /// Could not load the video data representation. + case videoLoadFailed = 5 + /// The image tiff representation could not be loaded from the `NSImage`. + case imageConversionFailed = 6 + /// The loaded `Data` from the `NSImage` could not be written as a file. + case imageSaveFailed = 7 + /// The image could not be compressed or the `NSImage` could not be created + /// from the compressed `Data`. + case imageCompressionFailed = 8 + /// The multi-video selection is not supported as it's not supported in + /// the app-facing package (`pickVideos` is missing). + /// The multi-video selection is supported when using `pickMedia` instead. + case multiVideoSelectionUnsupported = 9 +} + +/// 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 + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +/// This protocol should not be extended by any user class outside of the generated file. +protocol ImagePickerResult { + +} + +/// Generated class from Pigeon that represents data sent in messages. +struct ImagePickerSuccessResult: ImagePickerResult { + /// The temporary file paths as a result of picking the images and/or videos. + var filePaths: [String] + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ImagePickerSuccessResult? { + let filePaths = pigeonVar_list[0] as! [String] + + return ImagePickerSuccessResult( + filePaths: filePaths + ) + } + func toList() -> [Any?] { + return [ + filePaths + ] + } +} + +/// Generated class from Pigeon that represents data sent in messages. +struct ImagePickerErrorResult: ImagePickerResult { + /// Potential error conditions for [ImagePickerApi] calls. + var error: ImagePickerError + /// Additional error message from the platform side. + var platformErrorMessage: String? = nil + + // swift-format-ignore: AlwaysUseLowerCamelCase + static func fromList(_ pigeonVar_list: [Any?]) -> ImagePickerErrorResult? { + let error = pigeonVar_list[0] as! ImagePickerError + let platformErrorMessage: String? = nilOrValue(pigeonVar_list[1]) + + return ImagePickerErrorResult( + error: error, + platformErrorMessage: platformErrorMessage + ) + } + func toList() -> [Any?] { + return [ + error, + platformErrorMessage, + ] + } +} + +private class MessagesPigeonCodecReader: FlutterStandardReader { + override func readValue(ofType type: UInt8) -> Any? { + switch type { + case 129: + let enumResultAsInt: Int? = nilOrValue(self.readValue() as! Int?) + if let enumResultAsInt = enumResultAsInt { + return ImagePickerError(rawValue: enumResultAsInt) + } + return nil + case 130: + return GeneralOptions.fromList(self.readValue() as! [Any?]) + case 131: + return MaxSize.fromList(self.readValue() as! [Any?]) + case 132: + return ImageSelectionOptions.fromList(self.readValue() as! [Any?]) + case 133: + return MediaSelectionOptions.fromList(self.readValue() as! [Any?]) + case 134: + return ImagePickerSuccessResult.fromList(self.readValue() as! [Any?]) + case 135: + return ImagePickerErrorResult.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? ImagePickerError { + super.writeByte(129) + super.writeValue(value.rawValue) + } else if let value = value as? GeneralOptions { + super.writeByte(130) + super.writeValue(value.toList()) + } else if let value = value as? MaxSize { + super.writeByte(131) + super.writeValue(value.toList()) + } else if let value = value as? ImageSelectionOptions { + super.writeByte(132) + super.writeValue(value.toList()) + } else if let value = value as? MediaSelectionOptions { + super.writeByte(133) + super.writeValue(value.toList()) + } else if let value = value as? ImagePickerSuccessResult { + super.writeByte(134) + super.writeValue(value.toList()) + } else if let value = value as? ImagePickerErrorResult { + super.writeByte(135) + 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 { + /// Returns whether [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// is supported on the current macOS version. + func supportsPHPicker() throws -> Bool + func pickImages( + options: ImageSelectionOptions, generalOptions: GeneralOptions, + completion: @escaping (Result) -> Void) + /// Currently, multi-video selection is unimplemented. + func pickVideos( + generalOptions: GeneralOptions, completion: @escaping (Result) -> Void + ) + func pickMedia( + options: MediaSelectionOptions, generalOptions: GeneralOptions, + completion: @escaping (Result) -> 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)" : "" + /// Returns whether [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// is supported on the current macOS version. + 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..d4731885301e --- /dev/null +++ b/packages/image_picker/image_picker_macos/pigeons/messages.dart @@ -0,0 +1,143 @@ +// 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; +} + +/// Possible error conditions for [ImagePickerApi] calls. +enum ImagePickerError { + /// The current macOS version doesn't support [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// which is supported on macOS 13+. + phpickerUnsupported, + + /// Could not show the picker due to the missing window. + windowNotFound, + + /// When a `PHPickerResult` can't load `NSImage`. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only images. + invalidImageSelection, + + /// When a `PHPickerResult` is not a video. This error should not be reached + /// as the filter in the `PHPickerConfiguration` is set to accept only videos. + invalidVideoSelection, + + /// Could not load the image object as `NSImage`. + imageLoadFailed, + + /// Could not load the video data representation. + videoLoadFailed, + + /// The image tiff representation could not be loaded from the `NSImage`. + imageConversionFailed, + + /// The loaded `Data` from the `NSImage` could not be written as a file. + imageSaveFailed, + + /// The image could not be compressed or the `NSImage` could not be created + /// from the compressed `Data`. + imageCompressionFailed, + + /// The multi-video selection is not supported as it's not supported in + /// the app-facing package (`pickVideos` is missing). + /// The multi-video selection is supported when using `pickMedia` instead. + multiVideoSelectionUnsupported; +} + +sealed class ImagePickerResult {} + +class ImagePickerSuccessResult extends ImagePickerResult { + ImagePickerSuccessResult(this.filePaths); + + /// The temporary file paths as a result of picking the images and/or videos. + final List filePaths; +} + +class ImagePickerErrorResult extends ImagePickerResult { + ImagePickerErrorResult(this.error, {this.platformErrorMessage}); + + /// Potential error conditions for [ImagePickerApi] calls. + final ImagePickerError error; + + /// Additional error message from the platform side. + final String? platformErrorMessage; +} + +@HostApi(dartHostTestHandler: 'TestHostImagePickerApi') +abstract class ImagePickerApi { + /// Returns whether [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// is supported on the current macOS version. + 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 + + @async + ImagePickerResult pickImages( + ImageSelectionOptions options, + GeneralOptions generalOptions, + ); + + /// Currently, multi-video selection is unimplemented. + @async + ImagePickerResult pickVideos( + GeneralOptions generalOptions, + ); + @async + ImagePickerResult 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 d5f7181c903a..269c00c7e102 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.4.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.7.2 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..ae036c87cb83 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 @@ -3,15 +3,18 @@ // found in the LICENSE file. import 'package:file_selector_platform_interface/file_selector_platform_interface.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: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 +26,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 +41,84 @@ void main() { acceptedTypeGroups: anyNamed('acceptedTypeGroups'))) .thenAnswer((_) async => List.empty()); + when(mockImagePickerApi.supportsPHPicker()).thenAnswer((_) => false); + + when(mockImagePickerApi.pickImages(any, any)).thenAnswer( + (_) async => ImagePickerSuccessResult(filePaths: [])); + + when(mockImagePickerApi.pickVideos(any)).thenAnswer( + (_) async => ImagePickerSuccessResult(filePaths: [])); + + when(mockImagePickerApi.pickMedia(any, any)).thenAnswer( + (_) async => ImagePickerSuccessResult(filePaths: [])); + ImagePickerMacOS.fileSelector = mockFileSelectorPlatform; + TestHostImagePickerApi.setUp(mockImagePickerApi); + }); + + setUpAll(() { + // Mockito cannot generate a dummy value of type ImagePickerResult + provideDummy( + ImagePickerSuccessResult(filePaths: [])); }); + 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 +149,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,11 +197,343 @@ 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 ImagePickerSuccessResult(filePaths: 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 ImagePickerSuccessResult(filePaths: 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, + )), + )); + }, + ); + }, + ); + + void testThrowsPlatformExceptionForPHPicker({ + required String methodName, + required Future Function() underTest, + }) => + test( + '$methodName throws $PlatformException for PHPicker on platform API error', + () { + testWithPHPicker( + enabled: true, + body: () async { + for (final ImagePickerError error + in ImagePickerError.values) { + const String platformErrorMessage = + 'Example Platform Error Message'; + when(mockImagePickerApi.pickImages(any, any)) + .thenAnswer((_) async => ImagePickerErrorResult( + error: error, + platformErrorMessage: platformErrorMessage, + )); + await expectLater( + () => underTest(), + throwsA( + isA() + .having( + (PlatformException e) => e.code, + 'code', + equals(error.name), + ) + .having( + (PlatformException e) => e.details, + 'details', + equals(platformErrorMessage), + ), + ), + ); + verify(mockImagePickerApi.pickImages(any, any)).called(1); + } + }); + }, + ); + + testThrowsPlatformExceptionForPHPicker( + methodName: 'getImageFromSource', + underTest: () => plugin.getImageFromSource(source: ImageSource.gallery), + ); + + testThrowsPlatformExceptionForPHPicker( + methodName: 'getMultiImageWithOptions', + underTest: () => plugin.getMultiImageWithOptions(), + ); }); group('videos', () { - test('pickVideo passes the accepted type groups correctly', () async { + test('pickVideo delegate to getVideo', () async { + // The pickVideo is soft-deprecated in the platform interface + // and is only implemented for compatibility. Callers should be using getVideo. await plugin.pickVideo(source: ImageSource.gallery); + verify(plugin.getVideo(source: ImageSource.gallery)).called(1); + }); + + test('getVideo passes the accepted type groups correctly', () async { + await plugin.getVideo(source: ImageSource.gallery); final VerificationResult result = verify(mockFileSelectorPlatform .openFile(acceptedTypeGroups: captureAnyNamed('acceptedTypeGroups'))); @@ -119,17 +551,142 @@ 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'))); + }, + ); + }, + ); + + 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 ImagePickerSuccessResult(filePaths: filePaths); + }); + expect( + (await plugin.getVideo(source: ImageSource.gallery))?.path, + filePaths.first, + ); + }, + ); + }, + ); + + test( + 'getVideo throws $PlatformException for PHPicker on platform API error', + () { + testWithPHPicker( + enabled: true, + body: () async { + for (final ImagePickerError error in ImagePickerError.values) { + const String platformErrorMessage = + 'Example Platform Error Message'; + when(mockImagePickerApi.pickVideos(any)) + .thenAnswer((_) async => ImagePickerErrorResult( + error: error, + platformErrorMessage: platformErrorMessage, + )); + await expectLater( + () => plugin.getVideo(source: ImageSource.gallery), + throwsA( + isA() + .having( + (PlatformException e) => e.code, + 'code', + equals(error.name), + ) + .having( + (PlatformException e) => e.details, + 'details', + equals(platformErrorMessage), + ), + ), + ); + verify(mockImagePickerApi.pickVideos(any)).called(1); + } + }); + }, + ); }); group('media', () { @@ -162,6 +719,224 @@ 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 ImagePickerSuccessResult(filePaths: 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, + )), + )); + }, + ); + }, + ); + + test( + 'getMedia throws $PlatformException for PHPicker on platform API error', + () { + testWithPHPicker( + enabled: true, + body: () async { + for (final ImagePickerError error in ImagePickerError.values) { + const String platformErrorMessage = + 'Example Platform Error Message'; + when(mockImagePickerApi.pickMedia(any, any)) + .thenAnswer((_) async => ImagePickerErrorResult( + error: error, + platformErrorMessage: platformErrorMessage, + )); + await expectLater( + () => plugin.getMedia( + options: const MediaOptions(allowMultiple: true), + ), + throwsA( + isA() + .having( + (PlatformException e) => e.code, + 'code', + equals(error.name), + ) + .having( + (PlatformException e) => e.details, + 'details', + equals(platformErrorMessage), + ), + ), + ); + verify(mockImagePickerApi.pickMedia(any, any)).called(1); + } + }); + }, + ); }); } 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..1e9d0865a1c3 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 @@ -1,4 +1,4 @@ -// Mocks generated by Mockito 5.4.4 from annotations +// Mocks generated by Mockito 5.4.5 from annotations // in image_picker_macos/test/image_picker_macos_test.dart. // Do not manually edit this file. @@ -7,7 +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 'package:mockito/src/dummies.dart' as _i6; + +import 'test_api.g.dart' as _i4; // ignore_for_file: type=lint // ignore_for_file: avoid_redundant_argument_values @@ -17,6 +21,7 @@ import 'package:mockito/mockito.dart' as _i1; // ignore_for_file: deprecated_member_use_from_same_package // ignore_for_file: implementation_imports // ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable // ignore_for_file: prefer_const_constructors // ignore_for_file: unnecessary_parenthesis // ignore_for_file: camel_case_types @@ -141,3 +146,92 @@ 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<_i5.ImagePickerResult> pickImages( + _i5.ImageSelectionOptions? options, + _i5.GeneralOptions? generalOptions, + ) => + (super.noSuchMethod( + Invocation.method( + #pickImages, + [ + options, + generalOptions, + ], + ), + returnValue: _i3.Future<_i5.ImagePickerResult>.value( + _i6.dummyValue<_i5.ImagePickerResult>( + this, + Invocation.method( + #pickImages, + [ + options, + generalOptions, + ], + ), + )), + ) as _i3.Future<_i5.ImagePickerResult>); + + @override + _i3.Future<_i5.ImagePickerResult> pickVideos( + _i5.GeneralOptions? generalOptions) => + (super.noSuchMethod( + Invocation.method( + #pickVideos, + [generalOptions], + ), + returnValue: _i3.Future<_i5.ImagePickerResult>.value( + _i6.dummyValue<_i5.ImagePickerResult>( + this, + Invocation.method( + #pickVideos, + [generalOptions], + ), + )), + ) as _i3.Future<_i5.ImagePickerResult>); + + @override + _i3.Future<_i5.ImagePickerResult> pickMedia( + _i5.MediaSelectionOptions? options, + _i5.GeneralOptions? generalOptions, + ) => + (super.noSuchMethod( + Invocation.method( + #pickMedia, + [ + options, + generalOptions, + ], + ), + returnValue: _i3.Future<_i5.ImagePickerResult>.value( + _i6.dummyValue<_i5.ImagePickerResult>( + this, + Invocation.method( + #pickMedia, + [ + options, + generalOptions, + ], + ), + )), + ) as _i3.Future<_i5.ImagePickerResult>); +} 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..2116f7e7d245 --- /dev/null +++ b/packages/image_picker/image_picker_macos/test/test_api.g.dart @@ -0,0 +1,235 @@ +// 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.7.2), 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 ImagePickerError) { + buffer.putUint8(129); + writeValue(buffer, value.index); + } else if (value is GeneralOptions) { + buffer.putUint8(130); + writeValue(buffer, value.encode()); + } else if (value is MaxSize) { + buffer.putUint8(131); + writeValue(buffer, value.encode()); + } else if (value is ImageSelectionOptions) { + buffer.putUint8(132); + writeValue(buffer, value.encode()); + } else if (value is MediaSelectionOptions) { + buffer.putUint8(133); + writeValue(buffer, value.encode()); + } else if (value is ImagePickerSuccessResult) { + buffer.putUint8(134); + writeValue(buffer, value.encode()); + } else if (value is ImagePickerErrorResult) { + buffer.putUint8(135); + writeValue(buffer, value.encode()); + } else { + super.writeValue(buffer, value); + } + } + + @override + Object? readValueOfType(int type, ReadBuffer buffer) { + switch (type) { + case 129: + final int? value = readValue(buffer) as int?; + return value == null ? null : ImagePickerError.values[value]; + case 130: + return GeneralOptions.decode(readValue(buffer)!); + case 131: + return MaxSize.decode(readValue(buffer)!); + case 132: + return ImageSelectionOptions.decode(readValue(buffer)!); + case 133: + return MediaSelectionOptions.decode(readValue(buffer)!); + case 134: + return ImagePickerSuccessResult.decode(readValue(buffer)!); + case 135: + return ImagePickerErrorResult.decode(readValue(buffer)!); + default: + return super.readValueOfType(type, buffer); + } + } +} + +abstract class TestHostImagePickerApi { + static TestDefaultBinaryMessengerBinding? get _testBinaryMessengerBinding => + TestDefaultBinaryMessengerBinding.instance; + static const MessageCodec pigeonChannelCodec = _PigeonCodec(); + + /// Returns whether [PHPickerViewController](https://developer.apple.com/documentation/photosui/phpickerviewcontroller) + /// is supported on the current macOS version. + 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< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + '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< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + '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 ImagePickerResult 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< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + '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 ImagePickerResult 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< + Object?> pigeonVar_channel = BasicMessageChannel< + Object?>( + '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 ImagePickerResult 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())); + } + }); + } + } + } +}