[go: nahoru, domu]

[Video] Expose API for setting location metadata

 * Add API setLocation() on OutputOptions'
 * Apply the location of OutputOptions to the MediaMuxer.setLocation() in Recorder

 This CL also refactor the OutputOptions.
 * Move the common get/set methods from extended OutputOptions classes to OutputOptions/OutputOptions.Builder, for example move the implementation of get/setFileSizeLimit from FileOutputOptions to OutputOptions. This prevent from the duplicate code for having a common property.

Relnote: "Exposed API for setting location metadata to the saved video. An android.location.Location object can be set via new API androidx.camera.video.OutputOptions.Builder.setLocation(Location)."

Bug: 204197544
Test: ./gradlew camera:camera-video:connectedAndroidTest

Change-Id: I313a013445776ed730d6f98f57113e4998306e86
diff --git a/camera/camera-video/api/current.txt b/camera/camera-video/api/current.txt
index 94e5745..f8a2ad5 100644
--- a/camera/camera-video/api/current.txt
+++ b/camera/camera-video/api/current.txt
@@ -20,7 +20,6 @@
   }
 
   @RequiresApi(21) public final class FileDescriptorOutputOptions extends androidx.camera.video.OutputOptions {
-    method public long getFileSizeLimit();
     method public android.os.ParcelFileDescriptor getParcelFileDescriptor();
   }
 
@@ -28,24 +27,24 @@
     ctor public FileDescriptorOutputOptions.Builder(android.os.ParcelFileDescriptor);
     method public androidx.camera.video.FileDescriptorOutputOptions build();
     method public androidx.camera.video.FileDescriptorOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileDescriptorOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class FileOutputOptions extends androidx.camera.video.OutputOptions {
     method public java.io.File getFile();
-    method public long getFileSizeLimit();
   }
 
   @RequiresApi(21) public static final class FileOutputOptions.Builder {
     ctor public FileOutputOptions.Builder(java.io.File);
     method public androidx.camera.video.FileOutputOptions build();
     method public androidx.camera.video.FileOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class MediaStoreOutputOptions extends androidx.camera.video.OutputOptions {
     method public android.net.Uri getCollectionUri();
     method public android.content.ContentResolver getContentResolver();
     method public android.content.ContentValues getContentValues();
-    method public long getFileSizeLimit();
     field public static final android.content.ContentValues EMPTY_CONTENT_VALUES;
   }
 
@@ -54,10 +53,12 @@
     method public androidx.camera.video.MediaStoreOutputOptions build();
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setContentValues(android.content.ContentValues);
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.MediaStoreOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public abstract class OutputOptions {
-    method public abstract long getFileSizeLimit();
+    method public long getFileSizeLimit();
+    method public android.location.Location? getLocation();
     field public static final int FILE_SIZE_UNLIMITED = 0; // 0x0
   }
 
diff --git a/camera/camera-video/api/public_plus_experimental_current.txt b/camera/camera-video/api/public_plus_experimental_current.txt
index 94e5745..f8a2ad5 100644
--- a/camera/camera-video/api/public_plus_experimental_current.txt
+++ b/camera/camera-video/api/public_plus_experimental_current.txt
@@ -20,7 +20,6 @@
   }
 
   @RequiresApi(21) public final class FileDescriptorOutputOptions extends androidx.camera.video.OutputOptions {
-    method public long getFileSizeLimit();
     method public android.os.ParcelFileDescriptor getParcelFileDescriptor();
   }
 
@@ -28,24 +27,24 @@
     ctor public FileDescriptorOutputOptions.Builder(android.os.ParcelFileDescriptor);
     method public androidx.camera.video.FileDescriptorOutputOptions build();
     method public androidx.camera.video.FileDescriptorOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileDescriptorOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class FileOutputOptions extends androidx.camera.video.OutputOptions {
     method public java.io.File getFile();
-    method public long getFileSizeLimit();
   }
 
   @RequiresApi(21) public static final class FileOutputOptions.Builder {
     ctor public FileOutputOptions.Builder(java.io.File);
     method public androidx.camera.video.FileOutputOptions build();
     method public androidx.camera.video.FileOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class MediaStoreOutputOptions extends androidx.camera.video.OutputOptions {
     method public android.net.Uri getCollectionUri();
     method public android.content.ContentResolver getContentResolver();
     method public android.content.ContentValues getContentValues();
-    method public long getFileSizeLimit();
     field public static final android.content.ContentValues EMPTY_CONTENT_VALUES;
   }
 
@@ -54,10 +53,12 @@
     method public androidx.camera.video.MediaStoreOutputOptions build();
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setContentValues(android.content.ContentValues);
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.MediaStoreOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public abstract class OutputOptions {
-    method public abstract long getFileSizeLimit();
+    method public long getFileSizeLimit();
+    method public android.location.Location? getLocation();
     field public static final int FILE_SIZE_UNLIMITED = 0; // 0x0
   }
 
diff --git a/camera/camera-video/api/restricted_current.txt b/camera/camera-video/api/restricted_current.txt
index 94e5745..f8a2ad5 100644
--- a/camera/camera-video/api/restricted_current.txt
+++ b/camera/camera-video/api/restricted_current.txt
@@ -20,7 +20,6 @@
   }
 
   @RequiresApi(21) public final class FileDescriptorOutputOptions extends androidx.camera.video.OutputOptions {
-    method public long getFileSizeLimit();
     method public android.os.ParcelFileDescriptor getParcelFileDescriptor();
   }
 
@@ -28,24 +27,24 @@
     ctor public FileDescriptorOutputOptions.Builder(android.os.ParcelFileDescriptor);
     method public androidx.camera.video.FileDescriptorOutputOptions build();
     method public androidx.camera.video.FileDescriptorOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileDescriptorOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class FileOutputOptions extends androidx.camera.video.OutputOptions {
     method public java.io.File getFile();
-    method public long getFileSizeLimit();
   }
 
   @RequiresApi(21) public static final class FileOutputOptions.Builder {
     ctor public FileOutputOptions.Builder(java.io.File);
     method public androidx.camera.video.FileOutputOptions build();
     method public androidx.camera.video.FileOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.FileOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public final class MediaStoreOutputOptions extends androidx.camera.video.OutputOptions {
     method public android.net.Uri getCollectionUri();
     method public android.content.ContentResolver getContentResolver();
     method public android.content.ContentValues getContentValues();
-    method public long getFileSizeLimit();
     field public static final android.content.ContentValues EMPTY_CONTENT_VALUES;
   }
 
@@ -54,10 +53,12 @@
     method public androidx.camera.video.MediaStoreOutputOptions build();
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setContentValues(android.content.ContentValues);
     method public androidx.camera.video.MediaStoreOutputOptions.Builder setFileSizeLimit(long);
+    method public androidx.camera.video.MediaStoreOutputOptions.Builder setLocation(android.location.Location?);
   }
 
   @RequiresApi(21) public abstract class OutputOptions {
-    method public abstract long getFileSizeLimit();
+    method public long getFileSizeLimit();
+    method public android.location.Location? getLocation();
     field public static final int FILE_SIZE_UNLIMITED = 0; // 0x0
   }
 
diff --git a/camera/camera-video/build.gradle b/camera/camera-video/build.gradle
index b4e94bf..8a3a750 100644
--- a/camera/camera-video/build.gradle
+++ b/camera/camera-video/build.gradle
@@ -58,6 +58,7 @@
     androidTestImplementation(libs.truth)
     androidTestImplementation(libs.mockitoCore, excludes.bytebuddy) // DexMaker has it's own MockMaker
     androidTestImplementation(libs.dexmakerMockito, excludes.bytebuddy) // DexMaker has it's own MockMaker
+    androidTestImplementation(libs.autoValueAnnotations)
     androidTestImplementation(project(":camera:camera-lifecycle"))
     androidTestImplementation(project(":camera:camera-testing"))
     androidTestImplementation(libs.kotlinStdlib)
@@ -67,6 +68,7 @@
     androidTestImplementation libs.mockitoKotlin, {
         exclude group: 'org.mockito' // to keep control on the mockito version
     }
+    androidTestAnnotationProcessor(libs.autoValue)
 }
 
 android {
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/FakeOutputOptions.java b/camera/camera-video/src/androidTest/java/androidx/camera/video/FakeOutputOptions.java
new file mode 100644
index 0000000..06cf635
--- /dev/null
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/FakeOutputOptions.java
@@ -0,0 +1,63 @@
+/*
+ * Copyright 2022 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ *      http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.camera.video;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.RequiresApi;
+
+import com.google.auto.value.AutoValue;
+
+/** A fake implementation of {@link OutputOptions}. */
+// Java is used because @AutoValue is required.
+@RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+public class FakeOutputOptions extends OutputOptions {
+
+    private FakeOutputOptions(@NonNull FakeOutputOptionsInternal fakeOutputOptionsInternal) {
+        super(fakeOutputOptionsInternal);
+    }
+
+    /** The builder of the {@link FakeOutputOptions} object. */
+    @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
+    public static final class Builder extends OutputOptions.Builder<FakeOutputOptions, Builder> {
+
+        /** Creates a builder of the {@link FakeOutputOptions}. */
+        public Builder() {
+            super(new AutoValue_FakeOutputOptions_FakeOutputOptionsInternal.Builder());
+        }
+
+        /** Builds the {@link FakeOutputOptions} instance. */
+        @Override
+        @NonNull
+        public FakeOutputOptions build() {
+            return new FakeOutputOptions(
+                    ((FakeOutputOptionsInternal.Builder) mRootInternalBuilder).build());
+        }
+    }
+
+    @AutoValue
+    abstract static class FakeOutputOptionsInternal extends OutputOptions.OutputOptionsInternal {
+
+        @AutoValue.Builder
+        abstract static class Builder extends OutputOptions.OutputOptionsInternal.Builder<Builder> {
+
+            @SuppressWarnings("NullableProblems") // Nullable problem in AutoValue generated class
+            @Override
+            @NonNull
+            abstract FakeOutputOptionsInternal build();
+        }
+    }
+}
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/OutputOptionsTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/OutputOptionsTest.kt
index c221b5b..9c73beb 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/OutputOptionsTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/OutputOptionsTest.kt
@@ -19,13 +19,16 @@
 import android.content.ContentResolver
 import android.content.ContentValues
 import android.content.Context
+import android.location.Location
 import android.os.ParcelFileDescriptor
 import android.provider.MediaStore
 import androidx.test.core.app.ApplicationProvider
 import androidx.test.ext.junit.runners.AndroidJUnit4
 import androidx.test.filters.SdkSuppress
 import androidx.test.filters.SmallTest
+import androidx.testutils.assertThrows
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import org.junit.Test
 import org.junit.runner.RunWith
 import java.io.File
@@ -136,4 +139,52 @@
         }
         savedFile.delete()
     }
+
+    @Test
+    fun defaultLocationIsNull() {
+        val outputOptions = FakeOutputOptions.Builder().build()
+
+        assertThat(outputOptions.location).isNull()
+    }
+
+    @Test
+    fun setValidLocation() {
+        listOf(
+            createLocation(0.0, 0.0),
+            createLocation(90.0, 180.0),
+            createLocation(-90.0, -180.0),
+            createLocation(10.1234, -100.5678),
+        ).forEach { location ->
+            val outputOptions = FakeOutputOptions.Builder().setLocation(location).build()
+
+            assertWithMessage("Test $location failed")
+                .that(outputOptions.location).isEqualTo(location)
+        }
+    }
+
+    @Test
+    fun setInvalidLocation() {
+        listOf(
+            createLocation(Double.NaN, 0.0),
+            createLocation(0.0, Double.NaN),
+            createLocation(90.5, 0.0),
+            createLocation(-90.5, 0.0),
+            createLocation(0.0, 180.5),
+            createLocation(0.0, -180.5),
+        ).forEach { location ->
+            assertThrows(IllegalArgumentException::class.java) {
+                FakeOutputOptions.Builder().setLocation(location)
+            }
+        }
+    }
+
+    private fun createLocation(
+        latitude: Double,
+        longitude: Double,
+        provider: String = "FakeProvider"
+    ): Location =
+        Location(provider).apply {
+            this.latitude = latitude
+            this.longitude = longitude
+        }
 }
\ No newline at end of file
diff --git a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
index a1ffe7e..3d62333 100644
--- a/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
+++ b/camera/camera-video/src/androidTest/java/androidx/camera/video/RecorderTest.kt
@@ -26,6 +26,7 @@
 import android.content.ContentValues
 import android.content.Context
 import android.graphics.SurfaceTexture
+import android.location.Location
 import android.media.MediaMetadataRetriever
 import android.media.MediaRecorder
 import android.net.Uri
@@ -66,6 +67,7 @@
 import androidx.testutils.assertThrows
 import androidx.testutils.fail
 import com.google.common.truth.Truth.assertThat
+import com.google.common.truth.Truth.assertWithMessage
 import java.io.File
 import java.util.concurrent.Executor
 import java.util.concurrent.Semaphore
@@ -575,6 +577,14 @@
     }
 
     @Test
+    fun setLocation() {
+        // TODO(leohuang): add a test to verify negative latitude and longitude.
+        //  MediaMuxer.setLocation() causes a little loss of precision on negative values.
+        //  See b/232327925.
+        runLocationTest(createLocation(25.033267462243586, 121.56454121737946))
+    }
+
+    @Test
     fun checkStreamState() {
         clearInvocations(videoRecordEventListener)
         invokeSurfaceRequest()
@@ -1176,34 +1186,69 @@
     }
 
     private fun checkFileAudio(uri: Uri, hasAudio: Boolean) {
-        val mediaRetriever = MediaMetadataRetriever()
-        mediaRetriever.apply {
-            setDataSource(context, uri)
-            val value = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
+        MediaMetadataRetriever().apply {
+            try {
+                setDataSource(context, uri)
+                val value = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_AUDIO)
 
-            assertThat(value).isEqualTo(
-                if (hasAudio) {
-                    "yes"
-                } else {
-                    null
-                }
-            )
+                assertThat(value).isEqualTo(
+                    if (hasAudio) {
+                        "yes"
+                    } else {
+                        null
+                    }
+                )
+            } finally {
+                release()
+            }
         }
     }
 
     private fun checkFileVideo(uri: Uri, hasVideo: Boolean) {
-        val mediaRetriever = MediaMetadataRetriever()
-        mediaRetriever.apply {
-            setDataSource(context, uri)
-            val value = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
+        MediaMetadataRetriever().apply {
+            try {
+                setDataSource(context, uri)
+                val value = extractMetadata(MediaMetadataRetriever.METADATA_KEY_HAS_VIDEO)
 
-            assertThat(value).isEqualTo(
-                if (hasVideo) {
-                    "yes"
-                } else {
-                    null
-                }
-            )
+                assertThat(value).isEqualTo(
+                    if (hasVideo) {
+                        "yes"
+                    } else {
+                        null
+                    }
+                )
+            } finally {
+                release()
+            }
+        }
+    }
+
+    private fun checkLocation(uri: Uri, location: Location) {
+        MediaMetadataRetriever().apply {
+            try {
+                setDataSource(context, uri)
+                // Only test on mp4 output format, others will be ignored.
+                val mime = extractMetadata(MediaMetadataRetriever.METADATA_KEY_MIMETYPE)
+                assumeTrue("Unsupported mime = $mime",
+                    "video/mp4".equals(mime, ignoreCase = true))
+                val value = extractMetadata(MediaMetadataRetriever.METADATA_KEY_LOCATION)
+                assertThat(value).isNotNull()
+                // ex: (90, 180) => "+90.0000+180.0000/" (ISO-6709 standard)
+                val matchGroup =
+                    "([\\+-]?[0-9]+(\\.[0-9]+)?)([\\+-]?[0-9]+(\\.[0-9]+)?)".toRegex()
+                        .find(value!!) ?: fail("Fail on checking location metadata: $value")
+                val lat = matchGroup.groupValues[1].toDouble()
+                val lon = matchGroup.groupValues[3].toDouble()
+
+                // MediaMuxer.setLocation rounds the value to 4 decimal places
+                val tolerance = 0.0001
+                assertWithMessage("Fail on latitude. $lat($value) vs ${location.latitude}")
+                    .that(lat).isWithin(tolerance).of(location.latitude)
+                assertWithMessage("Fail on longitude. $lon($value) vs ${location.longitude}")
+                    .that(lon).isWithin(tolerance).of(location.longitude)
+            } finally {
+                release()
+            }
         }
     }
 
@@ -1256,4 +1301,43 @@
         recording.close()
         file.delete()
     }
+
+    private fun runLocationTest(location: Location) {
+        val recorder = Recorder.Builder().build()
+        invokeSurfaceRequest(recorder)
+        val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
+        val outputOptions = FileOutputOptions.Builder(file)
+            .setLocation(location)
+            .build()
+
+        val recording = recorder
+            .prepareRecording(context, outputOptions)
+            .start(CameraXExecutors.directExecutor(), videoRecordEventListener)
+
+        val inOrder = inOrder(videoRecordEventListener)
+        inOrder.verify(videoRecordEventListener, timeout(5000L))
+            .accept(any(VideoRecordEvent.Start::class.java))
+        inOrder.verify(videoRecordEventListener, timeout(15000L).atLeast(5))
+            .accept(any(VideoRecordEvent.Status::class.java))
+
+        recording.stopSafely()
+
+        inOrder.verify(videoRecordEventListener, timeout(FINALIZE_TIMEOUT))
+            .accept(any(VideoRecordEvent.Finalize::class.java))
+
+        val uri = Uri.fromFile(file)
+        checkLocation(uri, location)
+
+        file.delete()
+    }
+
+    private fun createLocation(
+        latitude: Double,
+        longitude: Double,
+        provider: String = "FakeProvider"
+    ): Location =
+        Location(provider).apply {
+            this.latitude = latitude
+            this.longitude = longitude
+        }
 }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/FileDescriptorOutputOptions.java b/camera/camera-video/src/main/java/androidx/camera/video/FileDescriptorOutputOptions.java
index 7c69be4..495ca67 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/FileDescriptorOutputOptions.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/FileDescriptorOutputOptions.java
@@ -43,8 +43,7 @@
 
     FileDescriptorOutputOptions(
             @NonNull FileDescriptorOutputOptionsInternal fileDescriptorOutputOptionsInternal) {
-        Preconditions.checkNotNull(fileDescriptorOutputOptionsInternal,
-                "FileDescriptorOutputOptionsInternal can't be null.");
+        super(fileDescriptorOutputOptionsInternal);
         mFileDescriptorOutputOptionsInternal = fileDescriptorOutputOptionsInternal;
     }
 
@@ -58,14 +57,6 @@
         return mFileDescriptorOutputOptionsInternal.getParcelFileDescriptor();
     }
 
-    /**
-     * Gets the limit for the file length in bytes.
-     */
-    @Override
-    public long getFileSizeLimit() {
-        return mFileDescriptorOutputOptionsInternal.getFileSizeLimit();
-    }
-
     @Override
     @NonNull
     public String toString() {
@@ -93,12 +84,10 @@
 
     /** The builder of the {@link FileDescriptorOutputOptions} object. */
     @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-    public static final class Builder implements
+    public static final class Builder extends
             OutputOptions.Builder<FileDescriptorOutputOptions, Builder> {
-        private final FileDescriptorOutputOptionsInternal.Builder mInternalBuilder =
-                new AutoValue_FileDescriptorOutputOptions_FileDescriptorOutputOptionsInternal
-                        .Builder()
-                        .setFileSizeLimit(FILE_SIZE_UNLIMITED);
+
+        private final FileDescriptorOutputOptionsInternal.Builder mInternalBuilder;
 
         /**
          * Creates a builder of the {@link FileDescriptorOutputOptions} with a file descriptor.
@@ -106,7 +95,10 @@
          * @param fileDescriptor the file descriptor to use as the output destination.
          */
         public Builder(@NonNull ParcelFileDescriptor fileDescriptor) {
+            super(new AutoValue_FileDescriptorOutputOptions_FileDescriptorOutputOptionsInternal
+                    .Builder());
             Preconditions.checkNotNull(fileDescriptor, "File descriptor can't be null.");
+            mInternalBuilder = (FileDescriptorOutputOptionsInternal.Builder) mRootInternalBuilder;
             mInternalBuilder.setParcelFileDescriptor(fileDescriptor);
         }
 
@@ -124,8 +116,7 @@
         @Override
         @NonNull
         public Builder setFileSizeLimit(long fileSizeLimitBytes) {
-            mInternalBuilder.setFileSizeLimit(fileSizeLimitBytes);
-            return this;
+            return super.setFileSizeLimit(fileSizeLimitBytes);
         }
 
         /** Builds the {@link FileDescriptorOutputOptions} instance. */
@@ -137,18 +128,17 @@
     }
 
     @AutoValue
-    abstract static class FileDescriptorOutputOptionsInternal {
+    abstract static class FileDescriptorOutputOptionsInternal extends OutputOptionsInternal {
         @NonNull
         abstract ParcelFileDescriptor getParcelFileDescriptor();
-        abstract long getFileSizeLimit();
 
+        @SuppressWarnings("NullableProblems") // Nullable problem in AutoValue generated class
         @AutoValue.Builder
-        abstract static class Builder {
+        abstract static class Builder extends OutputOptionsInternal.Builder<Builder> {
             @NonNull
             abstract Builder setParcelFileDescriptor(
                     @NonNull ParcelFileDescriptor parcelFileDescriptor);
-            @NonNull
-            abstract Builder setFileSizeLimit(long fileSizeLimitBytes);
+            @Override
             @NonNull
             abstract FileDescriptorOutputOptionsInternal build();
         }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/FileOutputOptions.java b/camera/camera-video/src/main/java/androidx/camera/video/FileOutputOptions.java
index 131a458..9bff991 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/FileOutputOptions.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/FileOutputOptions.java
@@ -39,8 +39,7 @@
     private final FileOutputOptionsInternal mFileOutputOptionsInternal;
 
     FileOutputOptions(@NonNull FileOutputOptionsInternal fileOutputOptionsInternal) {
-        Preconditions.checkNotNull(fileOutputOptionsInternal,
-                "FileOutputOptionsInternal can't be null.");
+        super(fileOutputOptionsInternal);
         mFileOutputOptionsInternal = fileOutputOptionsInternal;
     }
 
@@ -50,14 +49,6 @@
         return mFileOutputOptionsInternal.getFile();
     }
 
-    /**
-     * Gets the limit for the file length in bytes.
-     */
-    @Override
-    public long getFileSizeLimit() {
-        return mFileOutputOptionsInternal.getFileSizeLimit();
-    }
-
     @Override
     @NonNull
     public String toString() {
@@ -85,10 +76,9 @@
 
     /** The builder of the {@link FileOutputOptions} object. */
     @RequiresApi(21) // TODO(b/200306659): Remove and replace with annotation on package-info.java
-    public static final class Builder implements OutputOptions.Builder<FileOutputOptions, Builder> {
-        private final FileOutputOptionsInternal.Builder mInternalBuilder =
-                new AutoValue_FileOutputOptions_FileOutputOptionsInternal.Builder()
-                        .setFileSizeLimit(OutputOptions.FILE_SIZE_UNLIMITED);
+    public static final class Builder extends OutputOptions.Builder<FileOutputOptions, Builder> {
+
+        private final FileOutputOptionsInternal.Builder mInternalBuilder;
 
         /**
          * Creates a builder of the {@link FileOutputOptions} with a file object.
@@ -101,7 +91,9 @@
          */
         @SuppressWarnings("StreamFiles") // FileDescriptor API is in FileDescriptorOutputOptions
         public Builder(@NonNull File file) {
+            super(new AutoValue_FileOutputOptions_FileOutputOptionsInternal.Builder());
             Preconditions.checkNotNull(file, "File can't be null.");
+            mInternalBuilder = (FileOutputOptionsInternal.Builder) mRootInternalBuilder;
             mInternalBuilder.setFile(file);
         }
 
@@ -119,8 +111,7 @@
         @Override
         @NonNull
         public Builder setFileSizeLimit(long fileSizeLimitBytes) {
-            mInternalBuilder.setFileSizeLimit(fileSizeLimitBytes);
-            return this;
+            return super.setFileSizeLimit(fileSizeLimitBytes);
         }
 
         /** Builds the {@link FileOutputOptions} instance. */
@@ -132,20 +123,19 @@
     }
 
     @AutoValue
-    abstract static class FileOutputOptionsInternal {
+    abstract static class FileOutputOptionsInternal extends OutputOptions.OutputOptionsInternal {
+
         @NonNull
         abstract File getFile();
 
-        abstract long getFileSizeLimit();
-
+        @SuppressWarnings("NullableProblems") // Nullable problem in AutoValue generated class
         @AutoValue.Builder
-        abstract static class Builder {
+        abstract static class Builder extends OutputOptions.OutputOptionsInternal.Builder<Builder> {
+
             @NonNull
             abstract Builder setFile(@NonNull File file);
 
-            @NonNull
-            abstract Builder setFileSizeLimit(long fileSizeLimitBytes);
-
+            @Override
             @NonNull
             abstract FileOutputOptionsInternal build();
         }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/MediaStoreOutputOptions.java b/camera/camera-video/src/main/java/androidx/camera/video/MediaStoreOutputOptions.java
index 63d49ee..ebe74cd 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/MediaStoreOutputOptions.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/MediaStoreOutputOptions.java
@@ -70,8 +70,7 @@
 
     MediaStoreOutputOptions(
             @NonNull MediaStoreOutputOptionsInternal mediaStoreOutputOptionsInternal) {
-        Preconditions.checkNotNull(mediaStoreOutputOptionsInternal,
-                "MediaStoreOutputOptionsInternal can't be null.");
+        super(mediaStoreOutputOptionsInternal);
         mMediaStoreOutputOptionsInternal = mediaStoreOutputOptionsInternal;
     }
 
@@ -105,16 +104,6 @@
         return mMediaStoreOutputOptionsInternal.getContentValues();
     }
 
-    /**
-     * Gets the limit for the file length in bytes.
-     *
-     * @see Builder#setFileSizeLimit(long)
-     */
-    @Override
-    public long getFileSizeLimit() {
-        return mMediaStoreOutputOptionsInternal.getFileSizeLimit();
-    }
-
     @Override
     @NonNull
     public String toString() {
@@ -141,12 +130,10 @@
     }
 
     /** The builder of the {@link MediaStoreOutputOptions} object. */
-    public static final class Builder implements
+    public static final class Builder extends
             OutputOptions.Builder<MediaStoreOutputOptions, Builder> {
-        private final MediaStoreOutputOptionsInternal.Builder mInternalBuilder =
-                new AutoValue_MediaStoreOutputOptions_MediaStoreOutputOptionsInternal.Builder()
-                        .setContentValues(EMPTY_CONTENT_VALUES)
-                        .setFileSizeLimit(FILE_SIZE_UNLIMITED);
+
+        private final MediaStoreOutputOptionsInternal.Builder mInternalBuilder;
 
         /**
          * Creates a builder of the {@link MediaStoreOutputOptions} with media store options.
@@ -171,9 +158,13 @@
          * @param collectionUri the URI of the collection to insert into.
          */
         public Builder(@NonNull ContentResolver contentResolver, @NonNull Uri collectionUri) {
+            super(new AutoValue_MediaStoreOutputOptions_MediaStoreOutputOptionsInternal.Builder());
             Preconditions.checkNotNull(contentResolver, "Content resolver can't be null.");
             Preconditions.checkNotNull(collectionUri, "Collection Uri can't be null.");
-            mInternalBuilder.setContentResolver(contentResolver).setCollectionUri(collectionUri);
+            mInternalBuilder = (MediaStoreOutputOptionsInternal.Builder) mRootInternalBuilder;
+            mInternalBuilder.setContentResolver(contentResolver)
+                    .setCollectionUri(collectionUri)
+                    .setContentValues(new ContentValues());
         }
 
         /**
@@ -211,8 +202,7 @@
         @Override
         @NonNull
         public Builder setFileSizeLimit(long fileSizeLimitBytes) {
-            mInternalBuilder.setFileSizeLimit(fileSizeLimitBytes);
-            return this;
+            return super.setFileSizeLimit(fileSizeLimitBytes);
         }
 
         /** Builds the {@link MediaStoreOutputOptions} instance. */
@@ -224,25 +214,24 @@
     }
 
     @AutoValue
-    abstract static class MediaStoreOutputOptionsInternal {
+    abstract static class MediaStoreOutputOptionsInternal extends OutputOptionsInternal {
         @NonNull
         abstract ContentResolver getContentResolver();
         @NonNull
         abstract Uri getCollectionUri();
         @NonNull
         abstract ContentValues getContentValues();
-        abstract long getFileSizeLimit();
 
+        @SuppressWarnings("NullableProblems") // Nullable problem in AutoValue generated class
         @AutoValue.Builder
-        abstract static class Builder {
+        abstract static class Builder extends OutputOptionsInternal.Builder<Builder> {
             @NonNull
             abstract Builder setContentResolver(@NonNull ContentResolver contentResolver);
             @NonNull
             abstract Builder setCollectionUri(@NonNull Uri collectionUri);
             @NonNull
             abstract Builder setContentValues(@NonNull ContentValues contentValues);
-            @NonNull
-            abstract Builder setFileSizeLimit(long fileSizeLimitBytes);
+            @Override
             @NonNull
             abstract MediaStoreOutputOptionsInternal build();
         }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/OutputOptions.java b/camera/camera-video/src/main/java/androidx/camera/video/OutputOptions.java
index c5c113d..3b6f924 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/OutputOptions.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/OutputOptions.java
@@ -16,8 +16,12 @@
 
 package androidx.camera.video;
 
+import android.location.Location;
+
 import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
 import androidx.annotation.RequiresApi;
+import androidx.core.util.Preconditions;
 
 /**
  * Options for configuring output destination for generating a recording.
@@ -36,7 +40,10 @@
     /** Represents an unbound file size. */
     public static final int FILE_SIZE_UNLIMITED = 0;
 
-    OutputOptions() {
+    private final OutputOptionsInternal mOutputOptionsInternal;
+
+    OutputOptions(@NonNull OutputOptionsInternal outputOptionsInternal) {
+        mOutputOptionsInternal = outputOptionsInternal;
     }
 
     /**
@@ -44,12 +51,34 @@
      *
      * @return the file size limit in bytes.
      */
-    public abstract long getFileSizeLimit();
+    public long getFileSizeLimit() {
+        return mOutputOptionsInternal.getFileSizeLimit();
+    }
+
+    /**
+     * Returns a {@link Location} object representing the geographic location where the video was
+     * recorded.
+     *
+     * @return The location object or {@code null} if no location was set.
+     */
+    @Nullable
+    public Location getLocation() {
+        return mOutputOptionsInternal.getLocation();
+    }
 
     /**
      * The builder of the {@link OutputOptions}.
      */
-    interface Builder<T extends OutputOptions, B> {
+    @SuppressWarnings("unchecked") // Cast to type B
+    abstract static class Builder<T extends OutputOptions, B> {
+
+        final OutputOptionsInternal.Builder<?> mRootInternalBuilder;
+
+        Builder(@NonNull OutputOptionsInternal.Builder<?> builder) {
+            mRootInternalBuilder = builder;
+            // Apply default value
+            mRootInternalBuilder.setFileSizeLimit(FILE_SIZE_UNLIMITED);
+        }
 
         /**
          * Sets the limit for the file length in bytes.
@@ -57,12 +86,66 @@
          * <p>If not set, defaults to {@link #FILE_SIZE_UNLIMITED}.
          */
         @NonNull
-        B setFileSizeLimit(long bytes);
+        public B setFileSizeLimit(long bytes) {
+            mRootInternalBuilder.setFileSizeLimit(bytes);
+            return (B) this;
+        }
+
+        /**
+         * Sets a {@link Location} object representing a geographic location where the video was
+         * recorded.
+         *
+         * <p>When use with {@link Recorder}, the geographic location is stored in udta box if the
+         * output format is MP4, and is ignored for other formats. The geographic location is
+         * stored according to ISO-6709 standard.
+         *
+         * <p>If {@code null}, no location information will be saved with the video. Default
+         * value is {@code null}.
+         *
+         * @throws IllegalArgumentException if the latitude of the location is not in the range
+         * [-90, 90] or the longitude of the location is not in the range [-180, 180].
+         */
+        @NonNull
+        public B setLocation(@Nullable Location location) {
+            if (location != null) {
+                Preconditions.checkArgument(
+                        location.getLatitude() >= -90 && location.getLatitude() <= 90,
+                        "Latitude must be in the range [-90, 90]");
+                Preconditions.checkArgument(
+                        location.getLongitude() >= -180 && location.getLongitude() <= 180,
+                        "Longitude must be in the range [-180, 180]");
+            }
+            mRootInternalBuilder.setLocation(location);
+            return (B) this;
+        }
 
         /**
          * Builds the {@link OutputOptions} instance.
          */
         @NonNull
-        T build();
+        abstract T build();
+    }
+
+    // A base class of a @AutoValue class
+    abstract static class OutputOptionsInternal {
+
+        abstract long getFileSizeLimit();
+
+        @Nullable
+        abstract Location getLocation();
+
+        // A base class of a @AutoValue.Builder class
+        @SuppressWarnings("NullableProblems") // Nullable problem in AutoValue generated class
+        abstract static class Builder<B> {
+
+            @NonNull
+            abstract B setFileSizeLimit(long fileSizeLimitBytes);
+
+            @NonNull
+            abstract B setLocation(@Nullable Location location);
+
+            @NonNull
+            abstract OutputOptionsInternal build();
+        }
     }
 }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
index 66cd575..5cb6438 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/Recorder.java
@@ -31,6 +31,7 @@
 import android.annotation.SuppressLint;
 import android.content.ContentValues;
 import android.content.Context;
+import android.location.Location;
 import android.media.MediaMuxer;
 import android.media.MediaRecorder;
 import android.media.MediaScannerConnection;
@@ -1512,6 +1513,7 @@
                 return;
             }
 
+            MediaMuxer mediaMuxer;
             try {
                 MediaSpec mediaSpec = getObservableData(mMediaSpec);
                 int muxerOutputFormat =
@@ -1520,7 +1522,7 @@
                                 MediaSpec.outputFormatToMuxerFormat(
                                         MEDIA_SPEC_DEFAULT.getOutputFormat()))
                                 : MediaSpec.outputFormatToMuxerFormat(mediaSpec.getOutputFormat());
-                mMediaMuxer = recordingToStart.performOneTimeMediaMuxerCreation(muxerOutputFormat,
+                mediaMuxer = recordingToStart.performOneTimeMediaMuxerCreation(muxerOutputFormat,
                         uri -> mOutputUri = uri);
             } catch (IOException e) {
                 onInProgressRecordingInternalError(recordingToStart, ERROR_INVALID_OUTPUT_OPTIONS,
@@ -1528,16 +1530,30 @@
                 return;
             }
 
-            // TODO: Add more metadata to MediaMuxer, e.g. location information.
             if (mSurfaceTransformationInfo != null) {
-                mMediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
+                mediaMuxer.setOrientationHint(mSurfaceTransformationInfo.getRotationDegrees());
+            }
+            Location location = recordingToStart.getOutputOptions().getLocation();
+            if (location != null) {
+                try {
+                    mediaMuxer.setLocation((float) location.getLatitude(),
+                            (float) location.getLongitude());
+                } catch (IllegalArgumentException e) {
+                    mediaMuxer.release();
+                    onInProgressRecordingInternalError(recordingToStart,
+                            ERROR_INVALID_OUTPUT_OPTIONS, e);
+                    return;
+                }
             }
 
-            mVideoTrackIndex = mMediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
+            mVideoTrackIndex = mediaMuxer.addTrack(mVideoOutputConfig.getMediaFormat());
             if (isAudioEnabled()) {
-                mAudioTrackIndex = mMediaMuxer.addTrack(mAudioOutputConfig.getMediaFormat());
+                mAudioTrackIndex = mediaMuxer.addTrack(mAudioOutputConfig.getMediaFormat());
             }
-            mMediaMuxer.start();
+            mediaMuxer.start();
+
+            // MediaMuxer is successfully initialized, transfer the ownership to Recorder.
+            mMediaMuxer = mediaMuxer;
 
             // Write first data to ensure tracks are not empty
             writeVideoData(videoDataToWrite, recordingToStart);