[camera] Move camera streaming to platform interface (#5783)

diff --git a/packages/camera/camera_platform_interface/CHANGELOG.md b/packages/camera/camera_platform_interface/CHANGELOG.md
index 3cad35d..5ecd889 100644
--- a/packages/camera/camera_platform_interface/CHANGELOG.md
+++ b/packages/camera/camera_platform_interface/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 2.2.0
 
+* Adds image streaming to the platform interface.
 * Removes unnecessary imports.
 
 ## 2.1.6
diff --git a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart
index c856f34..babef14 100644
--- a/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart
+++ b/packages/camera/camera_platform_interface/lib/src/method_channel/method_channel_camera.dart
@@ -12,6 +12,8 @@
 import 'package:flutter/widgets.dart';
 import 'package:stream_transform/stream_transform.dart';
 
+import 'type_conversion.dart';
+
 const MethodChannel _channel = MethodChannel('plugins.flutter.io/camera');
 
 /// An implementation of [CameraPlatform] that uses method channels.
@@ -48,6 +50,12 @@
   final StreamController<DeviceEvent> deviceEventStreamController =
       StreamController<DeviceEvent>.broadcast();
 
+  // The stream to receive frames from the native code.
+  StreamSubscription<dynamic>? _platformImageStreamSubscription;
+
+  // The stream for vending frames to platform interface clients.
+  StreamController<CameraImageData>? _frameStreamController;
+
   Stream<CameraEvent> _cameraEvents(int cameraId) =>
       cameraEventStreamController.stream
           .where((CameraEvent event) => event.cameraId == cameraId);
@@ -268,6 +276,52 @@
       );
 
   @override
+  Stream<CameraImageData> onStreamedFrameAvailable(int cameraId,
+      {CameraImageStreamOptions? options}) {
+    _frameStreamController = StreamController<CameraImageData>(
+      onListen: _onFrameStreamListen,
+      onPause: _onFrameStreamPauseResume,
+      onResume: _onFrameStreamPauseResume,
+      onCancel: _onFrameStreamCancel,
+    );
+    return _frameStreamController!.stream;
+  }
+
+  void _onFrameStreamListen() {
+    _startPlatformStream();
+  }
+
+  Future<void> _startPlatformStream() async {
+    await _channel.invokeMethod<void>('startImageStream');
+    const EventChannel cameraEventChannel =
+        EventChannel('plugins.flutter.io/camera/imageStream');
+    _platformImageStreamSubscription =
+        cameraEventChannel.receiveBroadcastStream().listen((dynamic imageData) {
+      if (defaultTargetPlatform == TargetPlatform.iOS) {
+        try {
+          _channel.invokeMethod<void>('receivedImageStreamData');
+        } on PlatformException catch (e) {
+          throw CameraException(e.code, e.message);
+        }
+      }
+      _frameStreamController!
+          .add(cameraImageFromPlatformData(imageData as Map<dynamic, dynamic>));
+    });
+  }
+
+  FutureOr<void> _onFrameStreamCancel() async {
+    await _channel.invokeMethod<void>('stopImageStream');
+    await _platformImageStreamSubscription?.cancel();
+    _platformImageStreamSubscription = null;
+    _frameStreamController = null;
+  }
+
+  void _onFrameStreamPauseResume() {
+    throw CameraException('InvalidCall',
+        'Pause and resume are not supported for onStreamedFrameAvailable');
+  }
+
+  @override
   Future<void> setFlashMode(int cameraId, FlashMode mode) =>
       _channel.invokeMethod<void>(
         'setFlashMode',
diff --git a/packages/camera/camera_platform_interface/lib/src/method_channel/type_conversion.dart b/packages/camera/camera_platform_interface/lib/src/method_channel/type_conversion.dart
new file mode 100644
index 0000000..9dffbbf
--- /dev/null
+++ b/packages/camera/camera_platform_interface/lib/src/method_channel/type_conversion.dart
@@ -0,0 +1,61 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:typed_data';
+
+import 'package:flutter/foundation.dart';
+
+import '../types/types.dart';
+
+/// Converts method channel call [data] for `receivedImageStreamData` to a
+/// [CameraImageData].
+CameraImageData cameraImageFromPlatformData(Map<dynamic, dynamic> data) {
+  return CameraImageData(
+      format: _cameraImageFormatFromPlatformData(data['format']),
+      height: data['height'] as int,
+      width: data['width'] as int,
+      lensAperture: data['lensAperture'] as double?,
+      sensorExposureTime: data['sensorExposureTime'] as int?,
+      sensorSensitivity: data['sensorSensitivity'] as double?,
+      planes: List<CameraImagePlane>.unmodifiable(
+          (data['planes'] as List<dynamic>).map<CameraImagePlane>(
+              (dynamic planeData) => _cameraImagePlaneFromPlatformData(
+                  planeData as Map<dynamic, dynamic>))));
+}
+
+CameraImageFormat _cameraImageFormatFromPlatformData(dynamic data) {
+  return CameraImageFormat(_imageFormatGroupFromPlatformData(data), raw: data);
+}
+
+ImageFormatGroup _imageFormatGroupFromPlatformData(dynamic data) {
+  if (defaultTargetPlatform == TargetPlatform.android) {
+    switch (data) {
+      case 35: // android.graphics.ImageFormat.YUV_420_888
+        return ImageFormatGroup.yuv420;
+      case 256: // android.graphics.ImageFormat.JPEG
+        return ImageFormatGroup.jpeg;
+    }
+  }
+
+  if (defaultTargetPlatform == TargetPlatform.iOS) {
+    switch (data) {
+      case 875704438: // kCVPixelFormatType_420YpCbCr8BiPlanarVideoRange
+        return ImageFormatGroup.yuv420;
+
+      case 1111970369: // kCVPixelFormatType_32BGRA
+        return ImageFormatGroup.bgra8888;
+    }
+  }
+
+  return ImageFormatGroup.unknown;
+}
+
+CameraImagePlane _cameraImagePlaneFromPlatformData(Map<dynamic, dynamic> data) {
+  return CameraImagePlane(
+      bytes: data['bytes'] as Uint8List,
+      bytesPerPixel: data['bytesPerPixel'] as int?,
+      bytesPerRow: data['bytesPerRow'] as int,
+      height: data['height'] as int?,
+      width: data['width'] as int?);
+}
diff --git a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart
index daa19b8..eaa779a 100644
--- a/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart
+++ b/packages/camera/camera_platform_interface/lib/src/platform_interface/camera_platform.dart
@@ -149,6 +149,21 @@
     throw UnimplementedError('resumeVideoRecording() is not implemented.');
   }
 
+  /// A new streamed frame is available.
+  ///
+  /// Listening to this stream will start streaming, and canceling will stop.
+  /// Pausing will throw a [CameraException], as pausing the stream would cause
+  /// very high memory usage; to temporarily stop receiving frames, cancel, then
+  /// listen again later.
+  ///
+  ///
+  // TODO(bmparr): Add options to control streaming settings (e.g.,
+  // resolution and FPS).
+  Stream<CameraImageData> onStreamedFrameAvailable(int cameraId,
+      {CameraImageStreamOptions? options}) {
+    throw UnimplementedError('onStreamedFrameAvailable() is not implemented.');
+  }
+
   /// Sets the flash mode for the selected camera.
   /// On Web [FlashMode.auto] corresponds to [FlashMode.always].
   Future<void> setFlashMode(int cameraId, FlashMode mode) {
diff --git a/packages/camera/camera_platform_interface/lib/src/types/camera_image_data.dart b/packages/camera/camera_platform_interface/lib/src/types/camera_image_data.dart
new file mode 100644
index 0000000..6971dbb
--- /dev/null
+++ b/packages/camera/camera_platform_interface/lib/src/types/camera_image_data.dart
@@ -0,0 +1,126 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:typed_data';
+
+import 'package:flutter/foundation.dart';
+
+import '../../camera_platform_interface.dart';
+
+/// Options for configuring camera streaming.
+///
+/// Currently unused; this exists for future-proofing of the platform interface
+/// API.
+@immutable
+class CameraImageStreamOptions {}
+
+/// A single color plane of image data.
+///
+/// The number and meaning of the planes in an image are determined by its
+/// format.
+@immutable
+class CameraImagePlane {
+  /// Creates a new instance with the given bytes and optional metadata.
+  const CameraImagePlane({
+    required this.bytes,
+    required this.bytesPerRow,
+    this.bytesPerPixel,
+    this.height,
+    this.width,
+  });
+
+  /// Bytes representing this plane.
+  final Uint8List bytes;
+
+  /// The row stride for this color plane, in bytes.
+  final int bytesPerRow;
+
+  /// The distance between adjacent pixel samples in bytes, when available.
+  final int? bytesPerPixel;
+
+  /// Height of the pixel buffer, when available.
+  final int? height;
+
+  /// Width of the pixel buffer, when available.
+  final int? width;
+}
+
+/// Describes how pixels are represented in an image.
+@immutable
+class CameraImageFormat {
+  /// Create a new format with the given cross-platform group and raw underyling
+  /// platform identifier.
+  const CameraImageFormat(this.group, {required this.raw});
+
+  /// Describes the format group the raw image format falls into.
+  final ImageFormatGroup group;
+
+  /// Raw version of the format from the underlying platform.
+  ///
+  /// On Android, this should be an `int` from class
+  /// `android.graphics.ImageFormat`. See
+  /// https://developer.android.com/reference/android/graphics/ImageFormat
+  ///
+  /// On iOS, this should be a `FourCharCode` constant from Pixel Format
+  /// Identifiers. See
+  /// https://developer.apple.com/documentation/corevideo/1563591-pixel_format_identifiers
+  final dynamic raw;
+}
+
+/// A single complete image buffer from the platform camera.
+///
+/// This class allows for direct application access to the pixel data of an
+/// Image through one or more [Uint8List]. Each buffer is encapsulated in a
+/// [CameraImagePlane] that describes the layout of the pixel data in that
+/// plane. [CameraImageData] is not directly usable as a UI resource.
+///
+/// Although not all image formats are planar on all platforms, this class
+/// treats 1-dimensional images as single planar images.
+@immutable
+class CameraImageData {
+  /// Creates a new instance with the given format, planes, and metadata.
+  const CameraImageData({
+    required this.format,
+    required this.planes,
+    required this.height,
+    required this.width,
+    this.lensAperture,
+    this.sensorExposureTime,
+    this.sensorSensitivity,
+  });
+
+  /// Format of the image provided.
+  ///
+  /// Determines the number of planes needed to represent the image, and
+  /// the general layout of the pixel data in each [Uint8List].
+  final CameraImageFormat format;
+
+  /// Height of the image in pixels.
+  ///
+  /// For formats where some color channels are subsampled, this is the height
+  /// of the largest-resolution plane.
+  final int height;
+
+  /// Width of the image in pixels.
+  ///
+  /// For formats where some color channels are subsampled, this is the width
+  /// of the largest-resolution plane.
+  final int width;
+
+  /// The pixels planes for this image.
+  ///
+  /// The number of planes is determined by the format of the image.
+  final List<CameraImagePlane> planes;
+
+  /// The aperture settings for this image.
+  ///
+  /// Represented as an f-stop value.
+  final double? lensAperture;
+
+  /// The sensor exposure time for this image in nanoseconds.
+  final int? sensorExposureTime;
+
+  /// The sensor sensitivity in standard ISO arithmetic units.
+  final double? sensorSensitivity;
+}
diff --git a/packages/camera/camera_platform_interface/lib/src/types/types.dart b/packages/camera/camera_platform_interface/lib/src/types/types.dart
index 0c24839..3eb09fc 100644
--- a/packages/camera/camera_platform_interface/lib/src/types/types.dart
+++ b/packages/camera/camera_platform_interface/lib/src/types/types.dart
@@ -4,6 +4,7 @@
 
 export 'camera_description.dart';
 export 'camera_exception.dart';
+export 'camera_image_data.dart';
 export 'exposure_mode.dart';
 export 'flash_mode.dart';
 export 'focus_mode.dart';
diff --git a/packages/camera/camera_platform_interface/pubspec.yaml b/packages/camera/camera_platform_interface/pubspec.yaml
index ab163b4..473dcb5 100644
--- a/packages/camera/camera_platform_interface/pubspec.yaml
+++ b/packages/camera/camera_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+camera%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.1.6
+version: 2.2.0
 
 environment:
   sdk: '>=2.12.0 <3.0.0'
diff --git a/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart b/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart
index 7da4262..d096f00 100644
--- a/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart
+++ b/packages/camera/camera_platform_interface/test/method_channel/method_channel_camera_test.dart
@@ -1038,6 +1038,52 @@
               arguments: <String, Object?>{'cameraId': cameraId}),
         ]);
       });
+
+      test('Should start streaming', () async {
+        // Arrange
+        final MethodChannelMock channel = MethodChannelMock(
+          channelName: 'plugins.flutter.io/camera',
+          methods: <String, dynamic>{
+            'startImageStream': null,
+            'stopImageStream': null,
+          },
+        );
+
+        // Act
+        final StreamSubscription<CameraImageData> subscription = camera
+            .onStreamedFrameAvailable(cameraId)
+            .listen((CameraImageData imageData) {});
+
+        // Assert
+        expect(channel.log, <Matcher>[
+          isMethodCall('startImageStream', arguments: null),
+        ]);
+
+        subscription.cancel();
+      });
+
+      test('Should stop streaming', () async {
+        // Arrange
+        final MethodChannelMock channel = MethodChannelMock(
+          channelName: 'plugins.flutter.io/camera',
+          methods: <String, dynamic>{
+            'startImageStream': null,
+            'stopImageStream': null,
+          },
+        );
+
+        // Act
+        final StreamSubscription<CameraImageData> subscription = camera
+            .onStreamedFrameAvailable(cameraId)
+            .listen((CameraImageData imageData) {});
+        subscription.cancel();
+
+        // Assert
+        expect(channel.log, <Matcher>[
+          isMethodCall('startImageStream', arguments: null),
+          isMethodCall('stopImageStream', arguments: null),
+        ]);
+      });
     });
   });
 }
diff --git a/packages/camera/camera_platform_interface/test/method_channel/type_conversion_test.dart b/packages/camera/camera_platform_interface/test/method_channel/type_conversion_test.dart
new file mode 100644
index 0000000..a8ca45e
--- /dev/null
+++ b/packages/camera/camera_platform_interface/test/method_channel/type_conversion_test.dart
@@ -0,0 +1,85 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:typed_data';
+
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:camera_platform_interface/src/method_channel/type_conversion.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('CameraImageData can be created', () {
+    final CameraImageData cameraImage =
+        cameraImageFromPlatformData(<dynamic, dynamic>{
+      'format': 35,
+      'height': 1,
+      'width': 4,
+      'lensAperture': 1.8,
+      'sensorExposureTime': 9991324,
+      'sensorSensitivity': 92.0,
+      'planes': <dynamic>[
+        <dynamic, dynamic>{
+          'bytes': Uint8List.fromList(<int>[1, 2, 3, 4]),
+          'bytesPerPixel': 1,
+          'bytesPerRow': 4,
+          'height': 1,
+          'width': 4
+        }
+      ]
+    });
+    expect(cameraImage.height, 1);
+    expect(cameraImage.width, 4);
+    expect(cameraImage.format.group, ImageFormatGroup.yuv420);
+    expect(cameraImage.planes.length, 1);
+  });
+
+  test('CameraImageData has ImageFormatGroup.yuv420 for iOS', () {
+    debugDefaultTargetPlatformOverride = TargetPlatform.iOS;
+
+    final CameraImageData cameraImage =
+        cameraImageFromPlatformData(<dynamic, dynamic>{
+      'format': 875704438,
+      'height': 1,
+      'width': 4,
+      'lensAperture': 1.8,
+      'sensorExposureTime': 9991324,
+      'sensorSensitivity': 92.0,
+      'planes': <dynamic>[
+        <dynamic, dynamic>{
+          'bytes': Uint8List.fromList(<int>[1, 2, 3, 4]),
+          'bytesPerPixel': 1,
+          'bytesPerRow': 4,
+          'height': 1,
+          'width': 4
+        }
+      ]
+    });
+    expect(cameraImage.format.group, ImageFormatGroup.yuv420);
+  });
+
+  test('CameraImageData has ImageFormatGroup.yuv420 for Android', () {
+    debugDefaultTargetPlatformOverride = TargetPlatform.android;
+
+    final CameraImageData cameraImage =
+        cameraImageFromPlatformData(<dynamic, dynamic>{
+      'format': 35,
+      'height': 1,
+      'width': 4,
+      'lensAperture': 1.8,
+      'sensorExposureTime': 9991324,
+      'sensorSensitivity': 92.0,
+      'planes': <dynamic>[
+        <dynamic, dynamic>{
+          'bytes': Uint8List.fromList(<int>[1, 2, 3, 4]),
+          'bytesPerPixel': 1,
+          'bytesPerRow': 4,
+          'height': 1,
+          'width': 4
+        }
+      ]
+    });
+    expect(cameraImage.format.group, ImageFormatGroup.yuv420);
+  });
+}
diff --git a/packages/camera/camera_platform_interface/test/types/camera_image_data_test.dart b/packages/camera/camera_platform_interface/test/types/camera_image_data_test.dart
new file mode 100644
index 0000000..f06213e
--- /dev/null
+++ b/packages/camera/camera_platform_interface/test/types/camera_image_data_test.dart
@@ -0,0 +1,38 @@
+// Copyright 2013 The Flutter Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+import 'dart:typed_data';
+
+import 'package:camera_platform_interface/camera_platform_interface.dart';
+import 'package:flutter/foundation.dart';
+import 'package:flutter_test/flutter_test.dart';
+
+void main() {
+  test('CameraImageData can be created', () {
+    debugDefaultTargetPlatformOverride = TargetPlatform.android;
+    final CameraImageData cameraImage = CameraImageData(
+      format: const CameraImageFormat(ImageFormatGroup.jpeg, raw: 42),
+      height: 100,
+      width: 200,
+      lensAperture: 1.8,
+      sensorExposureTime: 11,
+      sensorSensitivity: 92.0,
+      planes: <CameraImagePlane>[
+        CameraImagePlane(
+            bytes: Uint8List.fromList(<int>[1, 2, 3, 4]),
+            bytesPerRow: 4,
+            bytesPerPixel: 2,
+            height: 100,
+            width: 200)
+      ],
+    );
+    expect(cameraImage.format.group, ImageFormatGroup.jpeg);
+    expect(cameraImage.lensAperture, 1.8);
+    expect(cameraImage.sensorExposureTime, 11);
+    expect(cameraImage.sensorSensitivity, 92.0);
+    expect(cameraImage.height, 100);
+    expect(cameraImage.width, 200);
+    expect(cameraImage.planes.length, 1);
+  });
+}