[camera] Reland implementation of flip camera while recording. App facing changes (#3496)

[camera] Reland implementation of flip camera while recording. App facing changes
diff --git a/packages/camera/camera/CHANGELOG.md b/packages/camera/camera/CHANGELOG.md
index f68d6e3..1a783b1 100644
--- a/packages/camera/camera/CHANGELOG.md
+++ b/packages/camera/camera/CHANGELOG.md
@@ -1,5 +1,6 @@
-## NEXT
+## 0.10.4
 
+* Allows camera to be switched while video recording.
 * Updates minimum Flutter version to 3.3.
 * Aligns Dart and Flutter SDK constraints.
 
diff --git a/packages/camera/camera/README.md b/packages/camera/camera/README.md
index db70831..095da3c 100644
--- a/packages/camera/camera/README.md
+++ b/packages/camera/camera/README.md
@@ -70,7 +70,7 @@
   if (state == AppLifecycleState.inactive) {
     cameraController.dispose();
   } else if (state == AppLifecycleState.resumed) {
-    onNewCameraSelected(cameraController.description);
+    _initializeCameraController(cameraController.description);
   }
 }
 ```
diff --git a/packages/camera/camera/example/integration_test/camera_test.dart b/packages/camera/camera/example/integration_test/camera_test.dart
index f0cc67f..6bef30e 100644
--- a/packages/camera/camera/example/integration_test/camera_test.dart
+++ b/packages/camera/camera/example/integration_test/camera_test.dart
@@ -265,6 +265,45 @@
     return completer.future;
   }
 
+  testWidgets('Set description while recording', (WidgetTester tester) async {
+    final List<CameraDescription> cameras = await availableCameras();
+    if (cameras.length < 2) {
+      return;
+    }
+
+    final CameraController controller = CameraController(
+      cameras[0],
+      ResolutionPreset.low,
+      enableAudio: false,
+    );
+
+    await controller.initialize();
+    await controller.prepareForVideoRecording();
+
+    await controller.startVideoRecording();
+    await controller.setDescription(cameras[1]);
+
+    expect(controller.description, cameras[1]);
+  });
+
+  testWidgets('Set description', (WidgetTester tester) async {
+    final List<CameraDescription> cameras = await availableCameras();
+    if (cameras.length < 2) {
+      return;
+    }
+
+    final CameraController controller = CameraController(
+      cameras[0],
+      ResolutionPreset.low,
+      enableAudio: false,
+    );
+
+    await controller.initialize();
+    await controller.setDescription(cameras[1]);
+
+    expect(controller.description, cameras[1]);
+  });
+
   testWidgets(
     'iOS image streaming with imageFormatGroup',
     (WidgetTester tester) async {
diff --git a/packages/camera/camera/example/lib/main.dart b/packages/camera/camera/example/lib/main.dart
index 2fa2ae6..73c9f05 100644
--- a/packages/camera/camera/example/lib/main.dart
+++ b/packages/camera/camera/example/lib/main.dart
@@ -120,7 +120,7 @@
     if (state == AppLifecycleState.inactive) {
       cameraController.dispose();
     } else if (state == AppLifecycleState.resumed) {
-      onNewCameraSelected(cameraController.description);
+      _initializeCameraController(cameraController.description);
     }
   }
   // #enddocregion AppLifecycle
@@ -597,10 +597,7 @@
               title: Icon(getCameraLensIcon(cameraDescription.lensDirection)),
               groupValue: controller?.description,
               value: cameraDescription,
-              onChanged:
-                  controller != null && controller!.value.isRecordingVideo
-                      ? null
-                      : onChanged,
+              onChanged: onChanged,
             ),
           ),
         );
@@ -633,17 +630,15 @@
   }
 
   Future<void> onNewCameraSelected(CameraDescription cameraDescription) async {
-    final CameraController? oldController = controller;
-    if (oldController != null) {
-      // `controller` needs to be set to null before getting disposed,
-      // to avoid a race condition when we use the controller that is being
-      // disposed. This happens when camera permission dialog shows up,
-      // which triggers `didChangeAppLifecycleState`, which disposes and
-      // re-creates the controller.
-      controller = null;
-      await oldController.dispose();
+    if (controller != null) {
+      return controller!.setDescription(cameraDescription);
+    } else {
+      return _initializeCameraController(cameraDescription);
     }
+  }
 
+  Future<void> _initializeCameraController(
+      CameraDescription cameraDescription) async {
     final CameraController cameraController = CameraController(
       cameraDescription,
       kIsWeb ? ResolutionPreset.max : ResolutionPreset.medium,
diff --git a/packages/camera/camera/lib/src/camera_controller.dart b/packages/camera/camera/lib/src/camera_controller.dart
index 7a396c1..69917d3 100644
--- a/packages/camera/camera/lib/src/camera_controller.dart
+++ b/packages/camera/camera/lib/src/camera_controller.dart
@@ -49,6 +49,7 @@
     required this.exposurePointSupported,
     required this.focusPointSupported,
     required this.deviceOrientation,
+    required this.description,
     this.lockedCaptureOrientation,
     this.recordingOrientation,
     this.isPreviewPaused = false,
@@ -56,7 +57,7 @@
   }) : _isRecordingPaused = isRecordingPaused;
 
   /// Creates a new camera controller state for an uninitialized controller.
-  const CameraValue.uninitialized()
+  const CameraValue.uninitialized(CameraDescription description)
       : this(
           isInitialized: false,
           isRecordingVideo: false,
@@ -70,6 +71,7 @@
           focusPointSupported: false,
           deviceOrientation: DeviceOrientation.portraitUp,
           isPreviewPaused: false,
+          description: description,
         );
 
   /// True after [CameraController.initialize] has completed successfully.
@@ -143,6 +145,9 @@
   /// The orientation of the currently running video recording.
   final DeviceOrientation? recordingOrientation;
 
+  /// The properties of the camera device controlled by this controller.
+  final CameraDescription description;
+
   /// Creates a modified copy of the object.
   ///
   /// Explicitly specified fields get the specified value, all other fields get
@@ -164,6 +169,7 @@
     Optional<DeviceOrientation>? lockedCaptureOrientation,
     Optional<DeviceOrientation>? recordingOrientation,
     bool? isPreviewPaused,
+    CameraDescription? description,
     Optional<DeviceOrientation>? previewPauseOrientation,
   }) {
     return CameraValue(
@@ -188,6 +194,7 @@
           ? this.recordingOrientation
           : recordingOrientation.orNull,
       isPreviewPaused: isPreviewPaused ?? this.isPreviewPaused,
+      description: description ?? this.description,
       previewPauseOrientation: previewPauseOrientation == null
           ? this.previewPauseOrientation
           : previewPauseOrientation.orNull,
@@ -211,7 +218,8 @@
         'lockedCaptureOrientation: $lockedCaptureOrientation, '
         'recordingOrientation: $recordingOrientation, '
         'isPreviewPaused: $isPreviewPaused, '
-        'previewPausedOrientation: $previewPauseOrientation)';
+        'previewPausedOrientation: $previewPauseOrientation, '
+        'description: $description)';
   }
 }
 
@@ -225,14 +233,14 @@
 class CameraController extends ValueNotifier<CameraValue> {
   /// Creates a new camera controller in an uninitialized state.
   CameraController(
-    this.description,
+    CameraDescription description,
     this.resolutionPreset, {
     this.enableAudio = true,
     this.imageFormatGroup,
-  }) : super(const CameraValue.uninitialized());
+  }) : super(CameraValue.uninitialized(description));
 
   /// The properties of the camera device controlled by this controller.
-  final CameraDescription description;
+  CameraDescription get description => value.description;
 
   /// The resolution this controller is targeting.
   ///
@@ -274,7 +282,12 @@
   /// Initializes the camera on the device.
   ///
   /// Throws a [CameraException] if the initialization fails.
-  Future<void> initialize() async {
+  Future<void> initialize() => _initializeWithDescription(description);
+
+  /// Initializes the camera on the device with the specified description.
+  ///
+  /// Throws a [CameraException] if the initialization fails.
+  Future<void> _initializeWithDescription(CameraDescription description) async {
     if (_isDisposed) {
       throw CameraException(
         'Disposed CameraController',
@@ -313,6 +326,7 @@
 
       value = value.copyWith(
         isInitialized: true,
+        description: description,
         previewSize: await initializeCompleter.future
             .then((CameraInitializedEvent event) => Size(
                   event.previewWidth,
@@ -380,6 +394,18 @@
     }
   }
 
+  /// Sets the description of the camera.
+  ///
+  /// Throws a [CameraException] if setting the description fails.
+  Future<void> setDescription(CameraDescription description) async {
+    if (value.isRecordingVideo) {
+      await CameraPlatform.instance.setDescriptionWhileRecording(description);
+      value = value.copyWith(description: description);
+    } else {
+      await _initializeWithDescription(description);
+    }
+  }
+
   /// Captures an image and returns the file where it was saved.
   ///
   /// Throws a [CameraException] if the capture fails.
diff --git a/packages/camera/camera/pubspec.yaml b/packages/camera/camera/pubspec.yaml
index f2196e7..839f064 100644
--- a/packages/camera/camera/pubspec.yaml
+++ b/packages/camera/camera/pubspec.yaml
@@ -4,7 +4,7 @@
   Dart.
 repository: https://github.com/flutter/packages/tree/main/packages/camera/camera
 issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+camera%22
-version: 0.10.3+2
+version: 0.10.4
 
 environment:
   sdk: ">=2.18.0 <4.0.0"
@@ -21,9 +21,9 @@
         default_package: camera_web
 
 dependencies:
-  camera_android: ^0.10.1
-  camera_avfoundation: ^0.9.9
-  camera_platform_interface: ^2.3.2
+  camera_android: ^0.10.5
+  camera_avfoundation: ^0.9.13
+  camera_platform_interface: ^2.4.0
   camera_web: ^0.3.1
   flutter:
     sdk: flutter
diff --git a/packages/camera/camera/test/camera_preview_test.dart b/packages/camera/camera/test/camera_preview_test.dart
index 6677fcf..c73e181 100644
--- a/packages/camera/camera/test/camera_preview_test.dart
+++ b/packages/camera/camera/test/camera_preview_test.dart
@@ -11,7 +11,10 @@
 
 class FakeController extends ValueNotifier<CameraValue>
     implements CameraController {
-  FakeController() : super(const CameraValue.uninitialized());
+  FakeController() : super(const CameraValue.uninitialized(fakeDescription));
+
+  static const CameraDescription fakeDescription = CameraDescription(
+      name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0);
 
   @override
   Future<void> dispose() async {
@@ -30,10 +33,6 @@
   void debugCheckIsDisposed() {}
 
   @override
-  CameraDescription get description => const CameraDescription(
-      name: '', lensDirection: CameraLensDirection.back, sensorOrientation: 0);
-
-  @override
   bool get enableAudio => false;
 
   @override
@@ -117,6 +116,12 @@
 
   @override
   Future<void> resumePreview() async {}
+
+  @override
+  Future<void> setDescription(CameraDescription description) async {}
+
+  @override
+  CameraDescription get description => value.description;
 }
 
 void main() {
diff --git a/packages/camera/camera/test/camera_value_test.dart b/packages/camera/camera/test/camera_value_test.dart
index 37168db..e23b865 100644
--- a/packages/camera/camera/test/camera_value_test.dart
+++ b/packages/camera/camera/test/camera_value_test.dart
@@ -13,6 +13,8 @@
 import 'package:flutter/services.dart';
 import 'package:flutter_test/flutter_test.dart';
 
+import 'camera_preview_test.dart';
+
 void main() {
   group('camera_value', () {
     test('Can be created', () {
@@ -32,6 +34,7 @@
         recordingOrientation: DeviceOrientation.portraitUp,
         focusPointSupported: true,
         previewPauseOrientation: DeviceOrientation.portraitUp,
+        description: FakeController.fakeDescription,
       );
 
       expect(cameraValue, isA<CameraValue>());
@@ -54,7 +57,8 @@
     });
 
     test('Can be created as uninitialized', () {
-      const CameraValue cameraValue = CameraValue.uninitialized();
+      const CameraValue cameraValue =
+          CameraValue.uninitialized(FakeController.fakeDescription);
 
       expect(cameraValue, isA<CameraValue>());
       expect(cameraValue.isInitialized, isFalse);
@@ -76,7 +80,8 @@
     });
 
     test('Can be copied with isInitialized', () {
-      const CameraValue cv = CameraValue.uninitialized();
+      const CameraValue cv =
+          CameraValue.uninitialized(FakeController.fakeDescription);
       final CameraValue cameraValue = cv.copyWith(isInitialized: true);
 
       expect(cameraValue, isA<CameraValue>());
@@ -99,7 +104,8 @@
     });
 
     test('Has aspectRatio after setting size', () {
-      const CameraValue cv = CameraValue.uninitialized();
+      const CameraValue cv =
+          CameraValue.uninitialized(FakeController.fakeDescription);
       final CameraValue cameraValue =
           cv.copyWith(isInitialized: true, previewSize: const Size(20, 10));
 
@@ -107,7 +113,8 @@
     });
 
     test('hasError is true after setting errorDescription', () {
-      const CameraValue cv = CameraValue.uninitialized();
+      const CameraValue cv =
+          CameraValue.uninitialized(FakeController.fakeDescription);
       final CameraValue cameraValue = cv.copyWith(errorDescription: 'error');
 
       expect(cameraValue.hasError, isTrue);
@@ -115,7 +122,8 @@
     });
 
     test('Recording paused is false when not recording', () {
-      const CameraValue cv = CameraValue.uninitialized();
+      const CameraValue cv =
+          CameraValue.uninitialized(FakeController.fakeDescription);
       final CameraValue cameraValue = cv.copyWith(
           isInitialized: true,
           isRecordingVideo: false,
@@ -126,25 +134,27 @@
 
     test('toString() works as expected', () {
       const CameraValue cameraValue = CameraValue(
-          isInitialized: false,
-          previewSize: Size(10, 10),
-          isRecordingPaused: false,
-          isRecordingVideo: false,
-          isTakingPicture: false,
-          isStreamingImages: false,
-          flashMode: FlashMode.auto,
-          exposureMode: ExposureMode.auto,
-          focusMode: FocusMode.auto,
-          exposurePointSupported: true,
-          focusPointSupported: true,
-          deviceOrientation: DeviceOrientation.portraitUp,
-          lockedCaptureOrientation: DeviceOrientation.portraitUp,
-          recordingOrientation: DeviceOrientation.portraitUp,
-          isPreviewPaused: true,
-          previewPauseOrientation: DeviceOrientation.portraitUp);
+        isInitialized: false,
+        previewSize: Size(10, 10),
+        isRecordingPaused: false,
+        isRecordingVideo: false,
+        isTakingPicture: false,
+        isStreamingImages: false,
+        flashMode: FlashMode.auto,
+        exposureMode: ExposureMode.auto,
+        focusMode: FocusMode.auto,
+        exposurePointSupported: true,
+        focusPointSupported: true,
+        deviceOrientation: DeviceOrientation.portraitUp,
+        lockedCaptureOrientation: DeviceOrientation.portraitUp,
+        recordingOrientation: DeviceOrientation.portraitUp,
+        isPreviewPaused: true,
+        previewPauseOrientation: DeviceOrientation.portraitUp,
+        description: FakeController.fakeDescription,
+      );
 
       expect(cameraValue.toString(),
-          'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, isPreviewPaused: true, previewPausedOrientation: DeviceOrientation.portraitUp)');
+          'CameraValue(isRecordingVideo: false, isInitialized: false, errorDescription: null, previewSize: Size(10.0, 10.0), isStreamingImages: false, flashMode: FlashMode.auto, exposureMode: ExposureMode.auto, focusMode: FocusMode.auto, exposurePointSupported: true, focusPointSupported: true, deviceOrientation: DeviceOrientation.portraitUp, lockedCaptureOrientation: DeviceOrientation.portraitUp, recordingOrientation: DeviceOrientation.portraitUp, isPreviewPaused: true, previewPausedOrientation: DeviceOrientation.portraitUp, description: CameraDescription(, CameraLensDirection.back, 0))');
     });
   });
 }