[camera] Set audio encoding bitrate when recording video (#3124)

Fixes flutter/flutter#38787
diff --git a/packages/camera/CHANGELOG.md b/packages/camera/CHANGELOG.md
index 8177467..fe5070d 100644
--- a/packages/camera/CHANGELOG.md
+++ b/packages/camera/CHANGELOG.md
@@ -1,3 +1,7 @@
+## 0.5.8+8
+
+* Fixed garbled audio (in video) by setting audio encoding bitrate.
+
 ## 0.5.8+7
 
 * Keep handling deprecated Android v1 classes for backward compatibility.
diff --git a/packages/camera/android/build.gradle b/packages/camera/android/build.gradle
index 3ff98a8..13495b2 100644
--- a/packages/camera/android/build.gradle
+++ b/packages/camera/android/build.gradle
@@ -51,4 +51,5 @@
 
 dependencies {
     testImplementation 'junit:junit:4.12'
+    testImplementation 'org.mockito:mockito-core:3.5.13'
 }
diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java
index 0fcda27..63e4d03 100644
--- a/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java
+++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/Camera.java
@@ -28,6 +28,7 @@
 import androidx.annotation.NonNull;
 import io.flutter.plugin.common.EventChannel;
 import io.flutter.plugin.common.MethodChannel.Result;
+import io.flutter.plugins.camera.media.MediaRecorderBuilder;
 import io.flutter.view.TextureRegistry.SurfaceTextureEntry;
 import java.io.File;
 import java.io.FileOutputStream;
@@ -82,7 +83,6 @@
     if (activity == null) {
       throw new IllegalStateException("No activity available!");
     }
-
     this.cameraName = cameraName;
     this.enableAudio = enableAudio;
     this.flutterTexture = flutterTexture;
@@ -120,23 +120,12 @@
     if (mediaRecorder != null) {
       mediaRecorder.release();
     }
-    mediaRecorder = new MediaRecorder();
 
-    // There's a specific order that mediaRecorder expects. Do not change the order
-    // of these function calls.
-    if (enableAudio) mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
-    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
-    mediaRecorder.setOutputFormat(recordingProfile.fileFormat);
-    if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec);
-    mediaRecorder.setVideoEncoder(recordingProfile.videoCodec);
-    mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate);
-    if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate);
-    mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate);
-    mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight);
-    mediaRecorder.setOutputFile(outputFilePath);
-    mediaRecorder.setOrientationHint(getMediaOrientation());
-
-    mediaRecorder.prepare();
+    mediaRecorder =
+        new MediaRecorderBuilder(recordingProfile, outputFilePath)
+            .setEnableAudio(enableAudio)
+            .setMediaOrientation(getMediaOrientation())
+            .build();
   }
 
   @SuppressLint("MissingPermission")
diff --git a/packages/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java b/packages/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java
new file mode 100644
index 0000000..57dc6e6
--- /dev/null
+++ b/packages/camera/android/src/main/java/io/flutter/plugins/camera/media/MediaRecorderBuilder.java
@@ -0,0 +1,73 @@
+// Copyright 2019 The Chromium Authors. All rights reserved.
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+package io.flutter.plugins.camera.media;
+
+import android.media.CamcorderProfile;
+import android.media.MediaRecorder;
+import androidx.annotation.NonNull;
+import java.io.IOException;
+
+public class MediaRecorderBuilder {
+  static class MediaRecorderFactory {
+    MediaRecorder makeMediaRecorder() {
+      return new MediaRecorder();
+    }
+  }
+
+  private final String outputFilePath;
+  private final CamcorderProfile recordingProfile;
+  private final MediaRecorderFactory recorderFactory;
+
+  private boolean enableAudio;
+  private int mediaOrientation;
+
+  public MediaRecorderBuilder(
+      @NonNull CamcorderProfile recordingProfile, @NonNull String outputFilePath) {
+    this(recordingProfile, outputFilePath, new MediaRecorderFactory());
+  }
+
+  MediaRecorderBuilder(
+      @NonNull CamcorderProfile recordingProfile,
+      @NonNull String outputFilePath,
+      MediaRecorderFactory helper) {
+    this.outputFilePath = outputFilePath;
+    this.recordingProfile = recordingProfile;
+    this.recorderFactory = helper;
+  }
+
+  public MediaRecorderBuilder setEnableAudio(boolean enableAudio) {
+    this.enableAudio = enableAudio;
+    return this;
+  }
+
+  public MediaRecorderBuilder setMediaOrientation(int orientation) {
+    this.mediaOrientation = orientation;
+    return this;
+  }
+
+  public MediaRecorder build() throws IOException {
+    MediaRecorder mediaRecorder = recorderFactory.makeMediaRecorder();
+
+    // There's a specific order that mediaRecorder expects. Do not change the order
+    // of these function calls.
+    if (enableAudio) {
+      mediaRecorder.setAudioSource(MediaRecorder.AudioSource.MIC);
+      mediaRecorder.setAudioEncodingBitRate(recordingProfile.audioBitRate);
+    }
+    mediaRecorder.setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    mediaRecorder.setOutputFormat(recordingProfile.fileFormat);
+    if (enableAudio) mediaRecorder.setAudioEncoder(recordingProfile.audioCodec);
+    mediaRecorder.setVideoEncoder(recordingProfile.videoCodec);
+    mediaRecorder.setVideoEncodingBitRate(recordingProfile.videoBitRate);
+    if (enableAudio) mediaRecorder.setAudioSamplingRate(recordingProfile.audioSampleRate);
+    mediaRecorder.setVideoFrameRate(recordingProfile.videoFrameRate);
+    mediaRecorder.setVideoSize(recordingProfile.videoFrameWidth, recordingProfile.videoFrameHeight);
+    mediaRecorder.setOutputFile(outputFilePath);
+    mediaRecorder.setOrientationHint(this.mediaOrientation);
+
+    mediaRecorder.prepare();
+
+    return mediaRecorder;
+  }
+}
diff --git a/packages/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java b/packages/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java
index db89eb2..c5ea83a 100644
--- a/packages/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java
+++ b/packages/camera/android/src/test/java/io/flutter/plugins/camera/DartMessengerTest.java
@@ -95,6 +95,7 @@
 
   private Map<String, String> decodeSentMessage(ByteBuffer sentMessage) {
     sentMessage.position(0);
+    //noinspection unchecked
     return (Map<String, String>) StandardMethodCodec.INSTANCE.decodeEnvelope(sentMessage);
   }
 
diff --git a/packages/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java b/packages/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java
new file mode 100644
index 0000000..f60e85d
--- /dev/null
+++ b/packages/camera/android/src/test/java/io/flutter/plugins/camera/media/MediaRecorderBuilderTest.java
@@ -0,0 +1,102 @@
+package io.flutter.plugins.camera.media;
+
+import static org.junit.Assert.assertNotNull;
+import static org.mockito.Mockito.*;
+
+import android.media.CamcorderProfile;
+import android.media.MediaRecorder;
+import java.io.IOException;
+import java.lang.reflect.Constructor;
+import org.junit.Test;
+import org.mockito.InOrder;
+
+public class MediaRecorderBuilderTest {
+  @Test
+  public void ctor_test() {
+    MediaRecorderBuilder builder =
+        new MediaRecorderBuilder(CamcorderProfile.get(CamcorderProfile.QUALITY_1080P), "");
+
+    assertNotNull(builder);
+  }
+
+  @Test
+  public void build_Should_set_values_in_correct_order_When_audio_is_disabled() throws IOException {
+    CamcorderProfile recorderProfile = getEmptyCamcorderProfile();
+    MediaRecorderBuilder.MediaRecorderFactory mockFactory =
+        mock(MediaRecorderBuilder.MediaRecorderFactory.class);
+    MediaRecorder mockMediaRecorder = mock(MediaRecorder.class);
+    String outputFilePath = "mock_video_file_path";
+    int mediaOrientation = 1;
+    MediaRecorderBuilder builder =
+        new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory)
+            .setEnableAudio(false)
+            .setMediaOrientation(mediaOrientation);
+
+    when(mockFactory.makeMediaRecorder()).thenReturn(mockMediaRecorder);
+
+    MediaRecorder recorder = builder.build();
+
+    InOrder inOrder = inOrder(recorder);
+    inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    inOrder.verify(recorder).setOutputFormat(recorderProfile.fileFormat);
+    inOrder.verify(recorder).setVideoEncoder(recorderProfile.videoCodec);
+    inOrder.verify(recorder).setVideoEncodingBitRate(recorderProfile.videoBitRate);
+    inOrder.verify(recorder).setVideoFrameRate(recorderProfile.videoFrameRate);
+    inOrder
+        .verify(recorder)
+        .setVideoSize(recorderProfile.videoFrameWidth, recorderProfile.videoFrameHeight);
+    inOrder.verify(recorder).setOutputFile(outputFilePath);
+    inOrder.verify(recorder).setOrientationHint(mediaOrientation);
+    inOrder.verify(recorder).prepare();
+  }
+
+  @Test
+  public void build_Should_set_values_in_correct_order_When_audio_is_enabled() throws IOException {
+    CamcorderProfile recorderProfile = getEmptyCamcorderProfile();
+    MediaRecorderBuilder.MediaRecorderFactory mockFactory =
+        mock(MediaRecorderBuilder.MediaRecorderFactory.class);
+    MediaRecorder mockMediaRecorder = mock(MediaRecorder.class);
+    String outputFilePath = "mock_video_file_path";
+    int mediaOrientation = 1;
+    MediaRecorderBuilder builder =
+        new MediaRecorderBuilder(recorderProfile, outputFilePath, mockFactory)
+            .setEnableAudio(true)
+            .setMediaOrientation(mediaOrientation);
+
+    when(mockFactory.makeMediaRecorder()).thenReturn(mockMediaRecorder);
+
+    MediaRecorder recorder = builder.build();
+
+    InOrder inOrder = inOrder(recorder);
+    inOrder.verify(recorder).setAudioSource(MediaRecorder.AudioSource.MIC);
+    inOrder.verify(recorder).setAudioEncodingBitRate(recorderProfile.audioBitRate);
+    inOrder.verify(recorder).setVideoSource(MediaRecorder.VideoSource.SURFACE);
+    inOrder.verify(recorder).setOutputFormat(recorderProfile.fileFormat);
+    inOrder.verify(recorder).setAudioEncoder(recorderProfile.audioCodec);
+    inOrder.verify(recorder).setVideoEncoder(recorderProfile.videoCodec);
+    inOrder.verify(recorder).setVideoEncodingBitRate(recorderProfile.videoBitRate);
+    inOrder.verify(recorder).setAudioSamplingRate(recorderProfile.audioSampleRate);
+    inOrder.verify(recorder).setVideoFrameRate(recorderProfile.videoFrameRate);
+    inOrder
+        .verify(recorder)
+        .setVideoSize(recorderProfile.videoFrameWidth, recorderProfile.videoFrameHeight);
+    inOrder.verify(recorder).setOutputFile(outputFilePath);
+    inOrder.verify(recorder).setOrientationHint(mediaOrientation);
+    inOrder.verify(recorder).prepare();
+  }
+
+  private CamcorderProfile getEmptyCamcorderProfile() {
+    try {
+      Constructor<CamcorderProfile> constructor =
+          CamcorderProfile.class.getDeclaredConstructor(
+              int.class, int.class, int.class, int.class, int.class, int.class, int.class,
+              int.class, int.class, int.class, int.class, int.class);
+
+      constructor.setAccessible(true);
+      return constructor.newInstance(0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
+    } catch (Exception ignored) {
+    }
+
+    return null;
+  }
+}
diff --git a/packages/camera/pubspec.yaml b/packages/camera/pubspec.yaml
index cbe1b1b..64cae9b 100644
--- a/packages/camera/pubspec.yaml
+++ b/packages/camera/pubspec.yaml
@@ -2,7 +2,7 @@
 description: A Flutter plugin for getting information about and controlling the
   camera on Android and iOS. Supports previewing the camera feed, capturing images, capturing video,
   and streaming image buffers to dart.
-version: 0.5.8+7
+version: 0.5.8+8
 
 homepage: https://github.com/flutter/plugins/tree/master/packages/camera