[image_picker] getMedia platform changes (#4174)

Adds `getMedia` and `getMultipleMedia` methods to  image_picker_platform_interface.

precursor to https://github.com/flutter/packages/pull/3892

part of https://github.com/flutter/flutter/issues/89159
diff --git a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md
index f93b6ec..bcba89b 100644
--- a/packages/image_picker/image_picker_platform_interface/CHANGELOG.md
+++ b/packages/image_picker/image_picker_platform_interface/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 2.8.0
+
+* Adds `getMedia` method.
+
 ## 2.7.0
 
 * Adds `CameraDelegatingImagePickerPlatform` as a base class for platform
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart
index c2c39f9..b21fd29 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/method_channel/method_channel_image_picker.dart
@@ -253,6 +253,30 @@
   }
 
   @override
+  Future<List<XFile>> getMedia({
+    required MediaOptions options,
+  }) async {
+    final ImageOptions imageOptions = options.imageOptions;
+
+    final Map<String, dynamic> args = <String, dynamic>{
+      'maxImageWidth': imageOptions.maxWidth,
+      'maxImageHeight': imageOptions.maxHeight,
+      'imageQuality': imageOptions.imageQuality,
+      'allowMultiple': options.allowMultiple,
+    };
+
+    final List<XFile>? paths = await _channel
+        .invokeMethod<List<dynamic>?>(
+          'pickMedia',
+          args,
+        )
+        .then((List<dynamic>? paths) =>
+            paths?.map((dynamic path) => XFile(path as String)).toList());
+
+    return paths ?? <XFile>[];
+  }
+
+  @override
   Future<XFile?> getVideo({
     required ImageSource source,
     CameraDevice preferredCameraDevice = CameraDevice.rear,
@@ -280,13 +304,21 @@
     assert(result.containsKey('path') != result.containsKey('errorCode'));
 
     final String? type = result['type'] as String?;
-    assert(type == kTypeImage || type == kTypeVideo);
+    assert(
+      type == kTypeImage || type == kTypeVideo || type == kTypeMedia,
+    );
 
     RetrieveType? retrieveType;
-    if (type == kTypeImage) {
-      retrieveType = RetrieveType.image;
-    } else if (type == kTypeVideo) {
-      retrieveType = RetrieveType.video;
+    switch (type) {
+      case kTypeImage:
+        retrieveType = RetrieveType.image;
+        break;
+      case kTypeVideo:
+        retrieveType = RetrieveType.video;
+        break;
+      case kTypeMedia:
+        retrieveType = RetrieveType.media;
+        break;
     }
 
     PlatformException? exception;
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart
index e01caca..66c5d3b 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/platform_interface/image_picker_platform.dart
@@ -213,6 +213,24 @@
     throw UnimplementedError('getMultiImage() has not been implemented.');
   }
 
+  /// Returns a [List<XFile>] with the images and/or videos that were picked.
+  /// The images and videos come from the gallery.
+  ///
+  /// Where iOS supports HEIC images, Android 8 and below doesn't. Android 9 and
+  /// above only support HEIC images if used in addition to a size modification,
+  /// of which the usage is explained below.
+  ///
+  /// In Android, the MainActivity can be destroyed for various reasons.
+  /// If that happens, the result will be lost in this call. You can then
+  /// call [getLostData] when your app relaunches to retrieve the lost data.
+  ///
+  /// If no images or videos were picked, the return value is an empty list.
+  Future<List<XFile>> getMedia({
+    required MediaOptions options,
+  }) {
+    throw UnimplementedError('getMedia() has not been implemented.');
+  }
+
   /// Returns a [XFile] containing the video that was picked.
   ///
   /// The [source] argument controls where the video comes from. This can
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/image_options.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/image_options.dart
index 2cc01c9..374ff27 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/types/image_options.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/image_options.dart
@@ -2,6 +2,40 @@
 // Use of this source code is governed by a BSD-style license that can be
 // found in the LICENSE file.
 
+import 'types.dart';
+
+/// Specifies options for picking a single image from the device's camera or gallery.
+///
+/// This class inheritance is a byproduct of the api changing over time.
+/// It exists solely to avoid breaking changes.
+class ImagePickerOptions extends ImageOptions {
+  /// Creates an instance with the given [maxHeight], [maxWidth], [imageQuality],
+  /// [referredCameraDevice] and [requestFullMetadata].
+  const ImagePickerOptions({
+    super.maxHeight,
+    super.maxWidth,
+    super.imageQuality,
+    super.requestFullMetadata,
+    this.preferredCameraDevice = CameraDevice.rear,
+  }) : super();
+
+  /// Creates an instance with the given [maxHeight], [maxWidth], [imageQuality],
+  /// [referredCameraDevice] and [requestFullMetadata].
+  ImagePickerOptions.createAndValidate({
+    super.maxHeight,
+    super.maxWidth,
+    super.imageQuality,
+    super.requestFullMetadata,
+    this.preferredCameraDevice = CameraDevice.rear,
+  }) : super.createAndValidate();
+
+  /// Used to specify the camera to use when the `source` is [ImageSource.camera].
+  ///
+  /// Ignored if the source is not [ImageSource.camera], or the chosen camera is not
+  /// supported on the device. Defaults to [CameraDevice.rear].
+  final CameraDevice preferredCameraDevice;
+}
+
 /// Specifies image-specific options for picking.
 class ImageOptions {
   /// Creates an instance with the given [maxHeight], [maxWidth], [imageQuality]
@@ -13,6 +47,18 @@
     this.requestFullMetadata = true,
   });
 
+  /// Creates an instance with the given [maxHeight], [maxWidth], [imageQuality]
+  /// and [requestFullMetadata]. Throws if options are not valid.
+  ImageOptions.createAndValidate({
+    this.maxHeight,
+    this.maxWidth,
+    this.imageQuality,
+    this.requestFullMetadata = true,
+  }) {
+    _validateOptions(
+        maxWidth: maxWidth, maxHeight: maxHeight, imageQuality: imageQuality);
+  }
+
   /// The maximum width of the image, in pixels.
   ///
   /// If null, the image will only be resized if [maxHeight] is specified.
@@ -38,4 +84,19 @@
   //
   // Defaults to true.
   final bool requestFullMetadata;
+
+  /// Validates that all values are within required ranges. Throws if not.
+  static void _validateOptions(
+      {double? maxWidth, final double? maxHeight, int? imageQuality}) {
+    if (imageQuality != null && (imageQuality < 0 || imageQuality > 100)) {
+      throw ArgumentError.value(
+          imageQuality, 'imageQuality', 'must be between 0 and 100');
+    }
+    if (maxWidth != null && maxWidth < 0) {
+      throw ArgumentError.value(maxWidth, 'maxWidth', 'cannot be negative');
+    }
+    if (maxHeight != null && maxHeight < 0) {
+      throw ArgumentError.value(maxHeight, 'maxHeight', 'cannot be negative');
+    }
+  }
 }
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/image_picker_options.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/image_picker_options.dart
deleted file mode 100644
index 0d85c91..0000000
--- a/packages/image_picker/image_picker_platform_interface/lib/src/types/image_picker_options.dart
+++ /dev/null
@@ -1,50 +0,0 @@
-// 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 'types.dart';
-
-/// Specifies options for picking a single image from the device's camera or gallery.
-class ImagePickerOptions {
-  /// Creates an instance with the given [maxHeight], [maxWidth], [imageQuality],
-  /// [referredCameraDevice] and [requestFullMetadata].
-  const ImagePickerOptions({
-    this.maxHeight,
-    this.maxWidth,
-    this.imageQuality,
-    this.preferredCameraDevice = CameraDevice.rear,
-    this.requestFullMetadata = true,
-  });
-
-  /// The maximum width of the image, in pixels.
-  ///
-  /// If null, the image will only be resized if [maxHeight] is specified.
-  final double? maxWidth;
-
-  /// The maximum height of the image, in pixels.
-  ///
-  /// If null, the image will only be resized if [maxWidth] is specified.
-  final double? maxHeight;
-
-  /// Modifies the quality of the image, ranging from 0-100 where 100 is the
-  /// original/max quality.
-  ///
-  /// Compression is only supported for certain image types such as JPEG. If
-  /// compression is not supported for the image that is picked, a warning
-  /// message will be logged.
-  ///
-  /// If null, the image will be returned with the original quality.
-  final int? imageQuality;
-
-  /// Used to specify the camera to use when the `source` is [ImageSource.camera].
-  ///
-  /// Ignored if the source is not [ImageSource.camera], or the chosen camera is not
-  /// supported on the device. Defaults to [CameraDevice.rear].
-  final CameraDevice preferredCameraDevice;
-
-  /// If true, requests full image metadata, which may require extra permissions
-  /// on some platforms, (e.g., NSPhotoLibraryUsageDescription on iOS).
-  //
-  // Defaults to true.
-  final bool requestFullMetadata;
-}
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart
index 10af812..0f802f1 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/lost_data_response.dart
@@ -36,7 +36,8 @@
   /// An empty response should have [file], [exception] and [type] to be null.
   bool get isEmpty => _empty;
 
-  /// The file that was lost in a previous [getImage], [getMultiImage] or [getVideo] call due to MainActivity being destroyed.
+  /// The file that was lost in a previous [getImage], [getMultiImage],
+  /// [getVideo] or [getMedia] call due to MainActivity being destroyed.
   ///
   /// Can be null if [exception] exists.
   final XFile? file;
@@ -51,7 +52,7 @@
   /// Note that it is not the exception that caused the destruction of the MainActivity.
   final PlatformException? exception;
 
-  /// Can either be [RetrieveType.image] or [RetrieveType.video];
+  /// Can either be [RetrieveType.image], [RetrieveType.video], or [RetrieveType.media].
   ///
   /// If the lost data is empty, this will be null.
   final RetrieveType? type;
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/media_options.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/media_options.dart
new file mode 100644
index 0000000..70a048f
--- /dev/null
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/media_options.dart
@@ -0,0 +1,23 @@
+// 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:flutter/foundation.dart';
+
+import '../../image_picker_platform_interface.dart';
+
+/// Specifies options for selecting items when using [ImagePickerPlatform.getMedia].
+@immutable
+class MediaOptions {
+  /// Construct a new MediaOptions instance.
+  const MediaOptions({
+    this.imageOptions = const ImageOptions(),
+    required this.allowMultiple,
+  });
+
+  /// Options that will apply to images upon selection.
+  final ImageOptions imageOptions;
+
+  /// Whether to allow for selecting multiple media.
+  final bool allowMultiple;
+}
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/media_selection_type.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/media_selection_type.dart
new file mode 100644
index 0000000..cd01134
--- /dev/null
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/media_selection_type.dart
@@ -0,0 +1,14 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import '../../image_picker_platform_interface.dart';
+
+/// The type of media to allow the user to select with [ImagePickerPlatform.getMedia].
+enum MediaSelectionType {
+  /// Static pictures.
+  image,
+
+  /// Videos.
+  video,
+}
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/retrieve_type.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/retrieve_type.dart
index 445445e..94fed59 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/types/retrieve_type.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/retrieve_type.dart
@@ -8,5 +8,8 @@
   image,
 
   /// A video. See [ImagePicker.pickVideo].
-  video
+  video,
+
+  /// Either a video or a static picture. See [ImagePicker.pickMedia].
+  media,
 }
diff --git a/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart b/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart
index fcb76cc..0339d98 100644
--- a/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart
+++ b/packages/image_picker/image_picker_platform_interface/lib/src/types/types.dart
@@ -5,9 +5,10 @@
 export 'camera_delegate.dart';
 export 'camera_device.dart';
 export 'image_options.dart';
-export 'image_picker_options.dart';
 export 'image_source.dart';
 export 'lost_data_response.dart';
+export 'media_options.dart';
+export 'media_selection_type.dart';
 export 'multi_image_picker_options.dart';
 export 'picked_file/picked_file.dart';
 export 'retrieve_type.dart';
@@ -17,3 +18,6 @@
 
 /// Denotes that a video is being picked.
 const String kTypeVideo = 'video';
+
+/// Denotes that either a video or image is being picked.
+const String kTypeMedia = 'media';
diff --git a/packages/image_picker/image_picker_platform_interface/pubspec.yaml b/packages/image_picker/image_picker_platform_interface/pubspec.yaml
index 3f1e523..67a5070 100644
--- a/packages/image_picker/image_picker_platform_interface/pubspec.yaml
+++ b/packages/image_picker/image_picker_platform_interface/pubspec.yaml
@@ -4,7 +4,7 @@
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+image_picker%22
 # NOTE: We strongly prefer non-breaking changes, even at the expense of a
 # less-clean API. See https://flutter.dev/go/platform-interface-breaking-changes
-version: 2.7.0
+version: 2.8.0
 
 environment:
   sdk: ">=2.18.0 <4.0.0"
diff --git a/packages/image_picker/image_picker_platform_interface/test/method_channel_image_picker_test.dart b/packages/image_picker/image_picker_platform_interface/test/method_channel_image_picker_test.dart
index 244af39..cf92c2c 100644
--- a/packages/image_picker/image_picker_platform_interface/test/method_channel_image_picker_test.dart
+++ b/packages/image_picker/image_picker_platform_interface/test/method_channel_image_picker_test.dart
@@ -872,6 +872,152 @@
       });
     });
 
+    group('#getMedia', () {
+      test('calls the method correctly', () async {
+        returnValue = <String>['0'];
+        await picker.getMedia(options: const MediaOptions(allowMultiple: true));
+
+        expect(
+          log,
+          <Matcher>[
+            isMethodCall('pickMedia', arguments: <String, dynamic>{
+              'maxImageWidth': null,
+              'maxImageHeight': null,
+              'imageQuality': null,
+              'allowMultiple': true,
+            }),
+          ],
+        );
+      });
+
+      test('passes the selection options correctly', () async {
+        // Default options
+        returnValue = <String>['0'];
+        await picker.getMedia(options: const MediaOptions(allowMultiple: true));
+        // Various image options
+        returnValue = <String>['0'];
+        await picker.getMedia(
+          options: MediaOptions(
+            allowMultiple: true,
+            imageOptions: ImageOptions.createAndValidate(
+              maxWidth: 10.0,
+            ),
+          ),
+        );
+        await picker.getMedia(
+          options: MediaOptions(
+            allowMultiple: true,
+            imageOptions: ImageOptions.createAndValidate(
+              maxHeight: 10.0,
+            ),
+          ),
+        );
+        await picker.getMedia(
+          options: MediaOptions(
+            allowMultiple: true,
+            imageOptions: ImageOptions.createAndValidate(
+              imageQuality: 70,
+            ),
+          ),
+        );
+
+        expect(
+          log,
+          <Matcher>[
+            isMethodCall('pickMedia', arguments: <String, dynamic>{
+              'maxImageWidth': null,
+              'maxImageHeight': null,
+              'imageQuality': null,
+              'allowMultiple': true,
+            }),
+            isMethodCall('pickMedia', arguments: <String, dynamic>{
+              'maxImageWidth': 10.0,
+              'maxImageHeight': null,
+              'imageQuality': null,
+              'allowMultiple': true,
+            }),
+            isMethodCall('pickMedia', arguments: <String, dynamic>{
+              'maxImageWidth': null,
+              'maxImageHeight': 10.0,
+              'imageQuality': null,
+              'allowMultiple': true,
+            }),
+            isMethodCall('pickMedia', arguments: <String, dynamic>{
+              'maxImageWidth': null,
+              'maxImageHeight': null,
+              'imageQuality': 70,
+              'allowMultiple': true,
+            }),
+          ],
+        );
+      });
+
+      test('does not accept a negative width or height argument', () {
+        returnValue = <String>['0', '1'];
+        expect(
+          () => picker.getMedia(
+            options: MediaOptions(
+              allowMultiple: true,
+              imageOptions: ImageOptions.createAndValidate(
+                maxWidth: -1.0,
+              ),
+            ),
+          ),
+          throwsArgumentError,
+        );
+
+        expect(
+          () => picker.getMedia(
+            options: MediaOptions(
+              allowMultiple: true,
+              imageOptions: ImageOptions.createAndValidate(
+                maxHeight: -1.0,
+              ),
+            ),
+          ),
+          throwsArgumentError,
+        );
+      });
+
+      test('does not accept a invalid imageQuality argument', () {
+        returnValue = <String>['0', '1'];
+        expect(
+          () => picker.getMedia(
+            options: MediaOptions(
+              allowMultiple: true,
+              imageOptions: ImageOptions.createAndValidate(
+                imageQuality: -1,
+              ),
+            ),
+          ),
+          throwsArgumentError,
+        );
+
+        expect(
+          () => picker.getMedia(
+            options: MediaOptions(
+              allowMultiple: true,
+              imageOptions: ImageOptions.createAndValidate(
+                imageQuality: 101,
+              ),
+            ),
+          ),
+          throwsArgumentError,
+        );
+      });
+
+      test('handles a null path response gracefully', () async {
+        _ambiguate(TestDefaultBinaryMessengerBinding.instance)!
+            .defaultBinaryMessenger
+            .setMockMethodCallHandler(
+                picker.channel, (MethodCall methodCall) => null);
+        expect(
+            await picker.getMedia(
+                options: const MediaOptions(allowMultiple: true)),
+            <XFile>[]);
+      });
+    });
+
     group('#getVideo', () {
       test('passes the image source argument correctly', () async {
         await picker.getVideo(source: ImageSource.camera);