[go: nahoru, domu]

Allow ActiveRecording to be garbage collected

 Allows ActiveRecording to be garbage collected when the user stops
 referencing it. Previously, Recorder would reference the
 ActiveRecording so the VideoCapture<Recorder> use case would have to be
 unbound to stop the recording. This is accomplished by keeping records
 of recordings in an internal class (RecordingRecord) rather than
 holding on to the ActiveRecording object directly.

 PendingRecording.start() is now marked as @CheckResult to warn users if
 they don't hold a reference to the ActiveRecording that is returned.

Bug: 195596598
Test: New test added to RecorderTest
Change-Id: Ida319cc41f43f60237f084dab87091fd5b82e536
diff --git a/camera/camera-core/src/androidTest/java/androidx/camera/core/SurfaceRequestTest.java b/camera/camera-core/src/androidTest/java/androidx/camera/core/SurfaceRequestTest.java
index 926aa30..4389db1 100644
--- a/camera/camera-core/src/androidTest/java/androidx/camera/core/SurfaceRequestTest.java
+++ b/camera/camera-core/src/androidTest/java/androidx/camera/core/SurfaceRequestTest.java
@@ -30,6 +30,7 @@
 import androidx.annotation.NonNull;
 import androidx.camera.core.impl.DeferrableSurface;
 import androidx.camera.core.impl.utils.executor.CameraXExecutors;
+import androidx.camera.testing.GarbageCollectionUtil;
 import androidx.camera.testing.fakes.FakeCamera;
 import androidx.core.content.ContextCompat;
 import androidx.core.util.Consumer;
@@ -58,27 +59,9 @@
             SurfaceRequest.TransformationInfo.of(new Rect(), 0, Surface.ROTATION_0);
     private static final Consumer<SurfaceRequest.Result> NO_OP_RESULT_LISTENER = ignored -> {
     };
-    private static final long FINALIZE_TIMEOUT_MILLIS = 200L;
-    private static final int NUM_GC_ITERATIONS = 10;
     private static final Surface MOCK_SURFACE = mock(Surface.class);
     private final List<SurfaceRequest> mSurfaceRequests = new ArrayList<>();
 
-    private static void runFinalization() throws TimeoutException, InterruptedException {
-        ReferenceQueue<Object> finalizeAwaitQueue = new ReferenceQueue<>();
-        PhantomReference<Object> finalizeSignal;
-        // Ensure finalization occurs multiple times
-        for (int i = 0; i < NUM_GC_ITERATIONS; ++i) {
-            finalizeSignal = new PhantomReference<>(new Object(), finalizeAwaitQueue);
-            Runtime.getRuntime().gc();
-            Runtime.getRuntime().runFinalization();
-            if (finalizeAwaitQueue.remove(FINALIZE_TIMEOUT_MILLIS) == null) {
-                throw new TimeoutException(
-                        "Finalization failed on iteration " + (i + 1) + " of " + NUM_GC_ITERATIONS);
-            }
-            finalizeSignal.clear();
-        }
-    }
-
     @After
     public void tearDown() {
         // Ensure all requests complete
@@ -264,7 +247,7 @@
             // Null out the original reference to the SurfaceRequest. DeferrableSurface should be
             // the only reference remaining.
             request = null;
-            runFinalization();
+            GarbageCollectionUtil.runFinalization();
             boolean requestFinalized = (referenceQueue.poll() != null);
 
             // Assert.
@@ -295,7 +278,7 @@
             // Null out the original reference to the DeferrableSurface. SurfaceRequest should be
             // the only reference remaining.
             deferrableSurface = null;
-            runFinalization();
+            GarbageCollectionUtil.runFinalization();
             boolean deferrableSurfaceFinalized = (referenceQueue.poll() != null);
 
             // Assert.
diff --git a/camera/camera-testing/src/main/java/androidx/camera/testing/GarbageCollectionUtil.java b/camera/camera-testing/src/main/java/androidx/camera/testing/GarbageCollectionUtil.java
new file mode 100644
index 0000000..8b2797d
--- /dev/null
+++ b/camera/camera-testing/src/main/java/androidx/camera/testing/GarbageCollectionUtil.java
@@ -0,0 +1,53 @@
+/*
+ * Copyright 2021 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.testing;
+
+import java.lang.ref.PhantomReference;
+import java.lang.ref.ReferenceQueue;
+import java.util.concurrent.TimeoutException;
+
+/**
+ * Utility class for tests containing methods related to garbage collection.
+ */
+public final class GarbageCollectionUtil {
+
+    private static final long FINALIZE_TIMEOUT_MILLIS = 200L;
+    private static final int NUM_GC_ITERATIONS = 10;
+
+    /**
+     * Causes garbage collection and ensures finalization has run before returning.
+     */
+    public static void runFinalization() throws TimeoutException, InterruptedException {
+        ReferenceQueue<Object> finalizeAwaitQueue = new ReferenceQueue<>();
+        PhantomReference<Object> finalizeSignal;
+        // Ensure finalization occurs multiple times
+        for (int i = 0; i < NUM_GC_ITERATIONS; ++i) {
+            finalizeSignal = new PhantomReference<>(new Object(), finalizeAwaitQueue);
+            Runtime.getRuntime().gc();
+            Runtime.getRuntime().runFinalization();
+            if (finalizeAwaitQueue.remove(FINALIZE_TIMEOUT_MILLIS) == null) {
+                throw new TimeoutException(
+                        "Finalization failed on iteration " + (i + 1) + " of " + NUM_GC_ITERATIONS);
+            }
+            finalizeSignal.clear();
+        }
+    }
+
+    // Ensure this utility class can't be instantiated
+    private GarbageCollectionUtil() {
+    }
+}
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 fb4743a..0519710 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
@@ -39,6 +39,7 @@
 import androidx.camera.core.internal.CameraUseCaseAdapter
 import androidx.camera.testing.AudioUtil
 import androidx.camera.testing.CameraUtil
+import androidx.camera.testing.GarbageCollectionUtil
 import androidx.camera.testing.SurfaceTextureProvider
 import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_FILE_SIZE_LIMIT_REACHED
 import androidx.camera.video.VideoRecordEvent.Finalize.ERROR_SOURCE_INACTIVE
@@ -180,13 +181,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -221,21 +219,19 @@
             .setContentValues(contentValues)
             .build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
         var uri: Uri = Uri.EMPTY
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor()
-        ) {
-            if (it is VideoRecordEvent.Status) {
-                statusSemaphore.release()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor()) {
+                if (it is VideoRecordEvent.Status) {
+                    statusSemaphore.release()
+                }
+                if (it is VideoRecordEvent.Finalize) {
+                    uri = it.outputResults.outputUri
+                    finalizeSemaphore.release()
+                }
             }
-            if (it is VideoRecordEvent.Finalize) {
-                uri = it.outputResults.outputUri
-                finalizeSemaphore.release()
-            }
-        }.withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+            .withAudioEnabled()
+            .start()
 
         assertThat(statusSemaphore.tryAcquire(5, 15000L, TimeUnit.MILLISECONDS)).isTrue()
 
@@ -265,13 +261,10 @@
                 .setParcelFileDescriptor(pfd)
                 .build()
 
-            val pendingRecording = recorder.prepareRecording(outputOptions)
-            pendingRecording.withEventListener(
-                CameraXExecutors.directExecutor(),
-                videoRecordEventListener
-            ).withAudioEnabled()
-
-            val activeRecording = pendingRecording.start()
+            val activeRecording = recorder.prepareRecording(outputOptions)
+                .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+                .withAudioEnabled()
+                .start()
 
             val inOrder = inOrder(videoRecordEventListener)
             inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -319,13 +312,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         activeRecording.pause()
 
@@ -441,16 +431,12 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
         val inOrder = inOrder(videoRecordEventListener)
-
         // Start
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         inOrder.verify(videoRecordEventListener, timeout(1000L))
             .accept(any(VideoRecordEvent.Start::class.java))
@@ -522,11 +508,10 @@
             .setFileSizeLimit(fileSizeLimit)
             .build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).start()
+        val activeRecording = recorder
+            .prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .start()
 
         verify(
             videoRecordEventListener,
@@ -542,6 +527,7 @@
         assertThat(file.length()).isGreaterThan(0)
         assertThat(file.length()).isLessThan(fileSizeLimit)
 
+        activeRecording.close()
         file.delete()
     }
 
@@ -552,9 +538,6 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
-
         @Suppress("UNCHECKED_CAST")
         val streamStateObserver =
             mock(Observable.Observer::class.java) as Observable.Observer<VideoOutput.StreamState>
@@ -566,7 +549,11 @@
             eq(VideoOutput.StreamState.INACTIVE)
         )
 
-        val activeRecording = pendingRecording.start()
+        // Start
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
         // Starting recording should move Recorder to ACTIVE stream state
         inOrder.verify(streamStateObserver, timeout(1000L)).onNewData(
             eq(VideoOutput.StreamState.ACTIVE)
@@ -588,14 +575,14 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording1 = recorder.prepareRecording(outputOptions)
-        pendingRecording1.start()
+        val activeRecording = recorder.prepareRecording(outputOptions).start()
 
-        val pendingRecording2 = recorder.prepareRecording(outputOptions)
+        val pendingRecording = recorder.prepareRecording(outputOptions)
         assertThrows(java.lang.IllegalStateException::class.java) {
-            pendingRecording2.start()
+            pendingRecording.start()
         }
 
+        activeRecording.close()
         file.delete()
     }
 
@@ -605,13 +592,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         invokeSurfaceRequest()
 
@@ -635,15 +619,12 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
         recorder.onSourceStateChanged(VideoOutput.SourceState.INACTIVE)
 
-        pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         verify(videoRecordEventListener, timeout(FINALIZE_TIMEOUT))
             .accept(any(VideoRecordEvent.Finalize::class.java))
@@ -653,6 +634,7 @@
         val finalize = captor.value as VideoRecordEvent.Finalize
         assertThat(finalize.error).isEqualTo(ERROR_SOURCE_INACTIVE)
 
+        activeRecording.close()
         file.delete()
     }
 
@@ -667,13 +649,11 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
-        val activeRecording = pendingRecording.start()
         activeRecording.pause()
 
         invokeSurfaceRequest()
@@ -698,13 +678,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -739,13 +716,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -768,13 +742,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -802,13 +773,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -830,13 +798,11 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
-        val activeRecording = pendingRecording.start()
         activeRecording.pause()
         activeRecording.stopSafely()
 
@@ -854,15 +820,13 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
+        val inOrder = inOrder(videoRecordEventListener)
+        // Recording will be stopped by AutoCloseable.close() upon exiting use{} block
         val pendingRecording = recorder.prepareRecording(outputOptions)
         pendingRecording.withEventListener(
             CameraXExecutors.directExecutor(),
             videoRecordEventListener
-        )
-
-        val inOrder = inOrder(videoRecordEventListener)
-        // Recording will be stopped by AutoCloseable.close() upon exiting use{} block
-        pendingRecording.start().use {
+        ).start().use {
             invokeSurfaceRequest()
             inOrder.verify(videoRecordEventListener, timeout(1000L))
                 .accept(any(VideoRecordEvent.Start::class.java))
@@ -883,13 +847,11 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        pendingRecording.start()
+        val activeRecording = recorder
+            .prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -904,6 +866,42 @@
         inOrder.verify(videoRecordEventListener, timeout(FINALIZE_TIMEOUT))
             .accept(any(VideoRecordEvent.Finalize::class.java))
 
+        activeRecording.stop()
+        file.delete()
+    }
+
+    @Suppress("UNUSED_VALUE", "ASSIGNED_BUT_NEVER_ACCESSED_VARIABLE")
+    @Test
+    fun stop_whenActiveRecordingIsGarbageCollected() {
+        clearInvocations(videoRecordEventListener)
+        val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
+        val outputOptions = FileOutputOptions.builder().setFile(file).build()
+
+        val inOrder = inOrder(videoRecordEventListener)
+        var activeRecording: ActiveRecording? = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
+
+        // First ensure the recording gets some status events
+        invokeSurfaceRequest()
+        inOrder.verify(videoRecordEventListener, timeout(1000L))
+            .accept(any(VideoRecordEvent.Start::class.java))
+        inOrder.verify(videoRecordEventListener, timeout(15000L).atLeast(5))
+            .accept(any(VideoRecordEvent.Status::class.java))
+
+        // Remove reference to active recording and run GC. The recording should be stopped once
+        // the ActiveRecording's finalizer runs.
+        activeRecording = null
+        GarbageCollectionUtil.runFinalization()
+
+        // Ensure the event listener gets a finalize event. Note: the word "finalize" is very
+        // overloaded here. This event means the recording has finished, but does not relate to the
+        // finalizer that runs during garbage collection. However, that is what causes the
+        // recording to finish.
+        inOrder.verify(videoRecordEventListener, timeout(FINALIZE_TIMEOUT))
+            .accept(any(VideoRecordEvent.Finalize::class.java))
+
         file.delete()
     }
 
@@ -914,13 +912,10 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        ).withAudioEnabled()
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .withAudioEnabled()
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
@@ -972,13 +967,9 @@
         val file = File.createTempFile("CameraX", ".tmp").apply { deleteOnExit() }
         val outputOptions = FileOutputOptions.builder().setFile(file).build()
 
-        val pendingRecording = recorder.prepareRecording(outputOptions)
-        pendingRecording.withEventListener(
-            CameraXExecutors.directExecutor(),
-            videoRecordEventListener
-        )
-
-        val activeRecording = pendingRecording.start()
+        val activeRecording = recorder.prepareRecording(outputOptions)
+            .withEventListener(CameraXExecutors.directExecutor(), videoRecordEventListener)
+            .start()
 
         val inOrder = inOrder(videoRecordEventListener)
         inOrder.verify(videoRecordEventListener, timeout(1000L))
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/ActiveRecording.java b/camera/camera-video/src/main/java/androidx/camera/video/ActiveRecording.java
index ff350a8..53d7383 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/ActiveRecording.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/ActiveRecording.java
@@ -17,14 +17,11 @@
 package androidx.camera.video;
 
 import androidx.annotation.NonNull;
-import androidx.annotation.Nullable;
-import androidx.camera.core.Logger;
 import androidx.camera.core.impl.utils.CloseGuardHelper;
 import androidx.core.util.Consumer;
 import androidx.core.util.Preconditions;
 
 import java.util.concurrent.Executor;
-import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.atomic.AtomicBoolean;
 
 /**
@@ -45,25 +42,18 @@
  */
 public final class ActiveRecording implements AutoCloseable {
 
-    private static final String TAG = "ActiveRecording";
-
     // Indicates the recording has been explicitly stopped by users.
     private final AtomicBoolean mIsStopped = new AtomicBoolean(false);
     private final Recorder mRecorder;
+    private final long mRecordingId;
     private final OutputOptions mOutputOptions;
-    private final Consumer<VideoRecordEvent> mEventListener;
-    private final Executor mCallbackExecutor;
     private final CloseGuardHelper mCloseGuard = CloseGuardHelper.create();
-    private final boolean mAudioEnabled;
 
-    ActiveRecording(@NonNull Recorder recorder, @NonNull OutputOptions options,
-            @Nullable Executor callbackExecutor, @Nullable Consumer<VideoRecordEvent> listener,
-            boolean audioEnabled, boolean finalizedOnCreation) {
+    ActiveRecording(@NonNull Recorder recorder, long recordingId, @NonNull OutputOptions options,
+            boolean finalizedOnCreation) {
         mRecorder = recorder;
+        mRecordingId = recordingId;
         mOutputOptions = options;
-        mCallbackExecutor = callbackExecutor;
-        mEventListener = listener;
-        mAudioEnabled = audioEnabled;
 
         if (finalizedOnCreation) {
             mIsStopped.set(true);
@@ -73,29 +63,37 @@
     }
 
     /**
-     * Creates an {@link ActiveRecording} from a {@link PendingRecording}.
+     * Creates an {@link ActiveRecording} from a {@link PendingRecording} and recording ID.
+     *
+     * <p>The recording ID is expected to be unique to the recorder that generated the pending
+     * recording.
      */
     @NonNull
-    static ActiveRecording from(@NonNull PendingRecording pendingRecording) {
+    static ActiveRecording from(@NonNull PendingRecording pendingRecording, long recordingId) {
         Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
         return new ActiveRecording(pendingRecording.getRecorder(),
-                pendingRecording.getOutputOptions(), pendingRecording.getCallbackExecutor(),
-                pendingRecording.getEventListener(), pendingRecording.isAudioEnabled(),
+                recordingId,
+                pendingRecording.getOutputOptions(),
                 /*finalizedOnCreation=*/false);
     }
 
     /**
-     * Creates an {@link ActiveRecording} from a {@link PendingRecording} in a finalized state.
+     * Creates an {@link ActiveRecording} from a {@link PendingRecording} and recording ID in a
+     * finalized state.
      *
      * <p>This can be used if there was an error setting up the active recording and it would not
      * be able to be started.
+     *
+     * <p>The recording ID is expected to be unique to the recorder that generated the pending
+     * recording.
      */
     @NonNull
-    static ActiveRecording createFinalizedFrom(@NonNull PendingRecording pendingRecording) {
+    static ActiveRecording createFinalizedFrom(@NonNull PendingRecording pendingRecording,
+            long recordingId) {
         Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
         return new ActiveRecording(pendingRecording.getRecorder(),
-                pendingRecording.getOutputOptions(), pendingRecording.getCallbackExecutor(),
-                pendingRecording.getEventListener(), pendingRecording.isAudioEnabled(),
+                recordingId,
+                pendingRecording.getOutputOptions(),
                 /*finalizedOnCreation=*/true);
     }
 
@@ -104,10 +102,6 @@
         return mOutputOptions;
     }
 
-    boolean isAudioEnabled() {
-        return mAudioEnabled;
-    }
-
     /**
      * Pauses the current recording if active.
      *
@@ -168,19 +162,6 @@
     }
 
     /**
-     * Updates the recording status and callback to users.
-     */
-    void updateVideoRecordEvent(@NonNull VideoRecordEvent event) {
-        if (mCallbackExecutor != null && mEventListener != null) {
-            try {
-                mCallbackExecutor.execute(() -> mEventListener.accept(event));
-            } catch (RejectedExecutionException e) {
-                Logger.e(TAG, "The callback executor is invalid.", e);
-            }
-        }
-    }
-
-    /**
      * Close this recording, as if calling {@link #stop()}.
      *
      * <p>This method is invoked automatically on active recording instances managed by the {@code
@@ -203,4 +184,9 @@
             super.finalize();
         }
     }
+
+    /** Returns the recording ID which is unique to the recorder that generated this recording. */
+    long getRecordingId() {
+        return mRecordingId;
+    }
 }
diff --git a/camera/camera-video/src/main/java/androidx/camera/video/PendingRecording.java b/camera/camera-video/src/main/java/androidx/camera/video/PendingRecording.java
index 9ac9a24..275b737 100644
--- a/camera/camera-video/src/main/java/androidx/camera/video/PendingRecording.java
+++ b/camera/camera-video/src/main/java/androidx/camera/video/PendingRecording.java
@@ -18,6 +18,7 @@
 
 import android.Manifest;
 
+import androidx.annotation.CheckResult;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
 import androidx.annotation.RequiresPermission;
@@ -138,11 +139,16 @@
      * found in that event's {@link VideoRecordEvent.Finalize#getError()} method. The returned
      * {@link ActiveRecording} will be in a finalized state, and all controls will be no-ops.
      *
+     * <p>If the returned {@link ActiveRecording} is garbage collected, the recording will be
+     * automatically stopped. A reference to the active recording must be maintained as long as
+     * the recording needs to be active.
+     *
      * @throws IllegalStateException if the associated Recorder currently has an unfinished
      * active recording, or if the recording has {@link #withAudioEnabled()} audio} but
      * {@link android.Manifest.permission#RECORD_AUDIO} is not granted.
      */
     @NonNull
+    @CheckResult
     public ActiveRecording start() {
         return mRecorder.start(this);
     }
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 2374287..d61936b 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
@@ -80,6 +80,7 @@
 import androidx.core.util.Consumer;
 import androidx.core.util.Preconditions;
 
+import com.google.auto.value.AutoValue;
 import com.google.common.util.concurrent.ListenableFuture;
 
 import java.io.File;
@@ -88,9 +89,11 @@
 import java.util.Collections;
 import java.util.EnumSet;
 import java.util.List;
+import java.util.Objects;
 import java.util.Set;
 import java.util.concurrent.ExecutionException;
 import java.util.concurrent.Executor;
+import java.util.concurrent.RejectedExecutionException;
 import java.util.concurrent.TimeUnit;
 
 /**
@@ -289,25 +292,27 @@
     private State mNonPendingState = null;
     @GuardedBy("mLock")
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
-    ActiveRecording mActiveRecording = null;
+    RecordingRecord mActiveRecordingRecord = null;
     // A recording that will be started once the previous recording has finalized or the
     // recorder has finished initializing.
     @GuardedBy("mLock")
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
-    ActiveRecording mPendingRecording = null;
+    RecordingRecord mPendingRecordingRecord = null;
     @GuardedBy("mLock")
     private SourceState mSourceState = SourceState.ACTIVE;
     @GuardedBy("mLock")
     private Throwable mErrorCause;
     @GuardedBy("mLock")
     private boolean mSurfaceRequested = false;
+    @GuardedBy("mLock")
+    private long mLastGeneratedRecordingId = 0L;
     //--------------------------------------------------------------------------------------------//
 
 
     ////////////////////////////////////////////////////////////////////////////////////////////////
     //                      Members only accessed on mSequentialExecutor                          //
     ////////////////////////////////////////////////////////////////////////////////////////////////
-    private ActiveRecording mInProgressRecording = null;
+    private RecordingRecord mInProgressRecording = null;
     private boolean mInProgressRecordingStopping = false;
     private boolean mAudioInitialized = false;
     private SurfaceRequest.TransformationInfo mSurfaceTransformationInfo = null;
@@ -413,7 +418,7 @@
     @RestrictTo(RestrictTo.Scope.LIBRARY)
     @Override
     public void onSourceStateChanged(@NonNull SourceState newState) {
-        ActiveRecording pendingRecordingToFinalize = null;
+        RecordingRecord pendingRecordingToFinalize = null;
         synchronized (mLock) {
             SourceState oldState = mSourceState;
             mSourceState = newState;
@@ -424,16 +429,16 @@
                         // Fall-through
                     case PENDING_PAUSED:
                         // Immediately finalize pending recording since it never started.
-                        pendingRecordingToFinalize = mPendingRecording;
-                        mPendingRecording = null;
+                        pendingRecordingToFinalize = mPendingRecordingRecord;
+                        mPendingRecordingRecord = null;
                         restoreNonPendingState(); // Equivalent to setState(mNonPendingState)
                         break;
                     case PAUSED:
                         // Fall-through
                     case RECORDING:
                         setState(State.STOPPING);
-                        ActiveRecording finalActiveRecording = mActiveRecording;
-                        mSequentialExecutor.execute(() -> stopInternal(finalActiveRecording,
+                        RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
+                        mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
                                 ERROR_SOURCE_INACTIVE));
                         break;
                     case STOPPING:
@@ -604,11 +609,12 @@
     @NonNull
     ActiveRecording start(@NonNull PendingRecording pendingRecording) {
         Preconditions.checkNotNull(pendingRecording, "The given PendingRecording cannot be null.");
-        ActiveRecording alreadyInProgressRecording = null;
-        ActiveRecording newActiveRecording = null;
+        RecordingRecord alreadyInProgressRecording = null;
         @VideoRecordError int error = ERROR_NONE;
         Throwable errorCause = null;
+        long recordingId;
         synchronized (mLock) {
+            recordingId = ++mLastGeneratedRecordingId;
             if (mSourceState == SourceState.INACTIVE) {
                 error = ERROR_SOURCE_INACTIVE;
                 errorCause = PENDING_RECORDING_ERROR_CAUSE_SOURCE_INACTIVE;
@@ -617,32 +623,32 @@
                     case PAUSED:
                         // Fall-through
                     case RECORDING:
-                        alreadyInProgressRecording = mActiveRecording;
+                        alreadyInProgressRecording = mActiveRecordingRecord;
                         break;
                     case PENDING_PAUSED:
                         // Fall-through
                     case PENDING_RECORDING:
                         // There is already a recording pending that hasn't been stopped.
                         alreadyInProgressRecording =
-                                Preconditions.checkNotNull(mPendingRecording);
+                                Preconditions.checkNotNull(mPendingRecordingRecord);
                         break;
                     case RESETTING:
                         // Fall-through
                     case STOPPING:
                         // Fall-through
                     case INITIALIZING:
-                        mPendingRecording = newActiveRecording = ActiveRecording.from(
-                                pendingRecording);
+                        mPendingRecordingRecord = RecordingRecord.from(pendingRecording,
+                                recordingId);
                         // The recording will automatically start once the initialization completes.
                         setState(State.PENDING_RECORDING);
                         break;
                     case IDLING:
                         Preconditions.checkState(
-                                mActiveRecording == null && mPendingRecording == null,
+                                mActiveRecordingRecord == null && mPendingRecordingRecord == null,
                                 "Expected recorder to be idle but a recording is either pending or "
                                         + "in progress.");
-                        mPendingRecording = newActiveRecording = ActiveRecording.from(
-                                pendingRecording);
+                        mPendingRecordingRecord = RecordingRecord.from(pendingRecording,
+                                recordingId);
                         setState(State.PENDING_RECORDING);
                         mSequentialExecutor.execute(this::tryServicePendingRecording);
                         break;
@@ -660,17 +666,19 @@
         } else if (error != ERROR_NONE) {
             Logger.e(TAG,
                     "Recording was started when the Recorder had encountered error " + errorCause);
-            newActiveRecording = ActiveRecording.createFinalizedFrom(pendingRecording);
             // Immediately update the listener if the Recorder encountered an error.
-            finalizePendingRecording(newActiveRecording, error, errorCause);
+            finalizePendingRecording(RecordingRecord.from(pendingRecording, recordingId),
+                    error, errorCause);
+            return ActiveRecording.createFinalizedFrom(pendingRecording, recordingId);
         }
 
-        return Preconditions.checkNotNull(newActiveRecording);
+        return ActiveRecording.from(pendingRecording, recordingId);
     }
 
     void pause(@NonNull ActiveRecording activeRecording) {
         synchronized (mLock) {
-            if (activeRecording != mPendingRecording && activeRecording !=  mActiveRecording) {
+            if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
+                    activeRecording, mActiveRecordingRecord)) {
                 // If this ActiveRecording is no longer active, log and treat as a no-op.
                 // This is not technically an error since the recording can be finalized
                 // asynchronously.
@@ -691,8 +699,8 @@
                     throw new IllegalStateException("Called pause() from invalid state: " + mState);
                 case RECORDING:
                     setState(State.PAUSED);
-                    ActiveRecording finalActiveRecording = mActiveRecording;
-                    mSequentialExecutor.execute(() -> pauseInternal(finalActiveRecording));
+                    RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
+                    mSequentialExecutor.execute(() -> pauseInternal(finalActiveRecordingRecord));
                     break;
                 case PENDING_PAUSED:
                     // Fall-through
@@ -714,7 +722,8 @@
 
     void resume(@NonNull ActiveRecording activeRecording) {
         synchronized (mLock) {
-            if (activeRecording != mPendingRecording && activeRecording != mActiveRecording) {
+            if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
+                    activeRecording, mActiveRecordingRecord)) {
                 // If this ActiveRecording is no longer active, log and treat as a no-op.
                 // This is not technically an error since the recording can be finalized
                 // asynchronously.
@@ -746,8 +755,8 @@
                     break;
                 case PAUSED:
                     setState(State.RECORDING);
-                    ActiveRecording finalActiveRecording = mActiveRecording;
-                    mSequentialExecutor.execute(() -> resumeInternal(finalActiveRecording));
+                    RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
+                    mSequentialExecutor.execute(() -> resumeInternal(finalActiveRecordingRecord));
                     break;
                 case ERROR:
                     // In an error state, the recording will already be finalized. Treat as a
@@ -758,9 +767,10 @@
     }
 
     void stop(@NonNull ActiveRecording activeRecording) {
-        ActiveRecording pendingRecordingToFinalize = null;
+        RecordingRecord pendingRecordingToFinalize = null;
         synchronized (mLock) {
-            if (activeRecording != mPendingRecording && activeRecording != mActiveRecording) {
+            if (!isSameRecording(activeRecording, mPendingRecordingRecord) && !isSameRecording(
+                    activeRecording, mActiveRecordingRecord)) {
                 // If this ActiveRecording is no longer active, log and treat as a no-op.
                 // This is not technically an error since the recording can be finalized
                 // asynchronously.
@@ -774,18 +784,20 @@
                     // Fall-through
                 case PENDING_PAUSED:
                     // Immediately finalize pending recording since it never started.
-                    Preconditions.checkState(activeRecording == mPendingRecording);
-                    pendingRecordingToFinalize = mPendingRecording;
-                    mPendingRecording = null;
+                    Preconditions.checkState(isSameRecording(activeRecording,
+                            mPendingRecordingRecord));
+                    pendingRecordingToFinalize = mPendingRecordingRecord;
+                    mPendingRecordingRecord = null;
                     restoreNonPendingState(); // Equivalent to setState(mNonPendingState)
                     break;
                 case STOPPING:
                     // Fall-through
                 case RESETTING:
-                    // We are already stopping or resetting, likely due to an error that stopped
-                    // the recording. Ensure this is the current active recording and treat as a
-                    // no-op. The active recording will be cleared once stop/reset is complete.
-                    Preconditions.checkState(activeRecording == mActiveRecording);
+                    // We are already resetting, likely due to an error that stopped the recording.
+                    // Ensure this is the current active recording and treat as a no-op. The
+                    // active recording will be cleared once stop/reset is complete.
+                    Preconditions.checkState(isSameRecording(activeRecording,
+                            mActiveRecordingRecord));
                     break;
                 case INITIALIZING:
                     // Fall-through
@@ -796,8 +808,8 @@
                     // Fall-through
                 case RECORDING:
                     setState(State.STOPPING);
-                    ActiveRecording finalActiveRecording = mActiveRecording;
-                    mSequentialExecutor.execute(() -> stopInternal(finalActiveRecording,
+                    RecordingRecord finalActiveRecordingRecord = mActiveRecordingRecord;
+                    mSequentialExecutor.execute(() -> stopInternal(finalActiveRecordingRecord,
                             ERROR_NONE));
                     break;
                 case ERROR:
@@ -814,7 +826,7 @@
         }
     }
 
-    private void finalizePendingRecording(@NonNull ActiveRecording recordingToFinalize,
+    private void finalizePendingRecording(@NonNull RecordingRecord recordingToFinalize,
             @VideoRecordError int error, @Nullable Throwable cause) {
         recordingToFinalize.updateVideoRecordEvent(
                 VideoRecordEvent.finalizeWithError(
@@ -862,7 +874,7 @@
                 case PAUSED:
                     // Fall-through
                 case RECORDING:
-                    if (mActiveRecording != mInProgressRecording) {
+                    if (mActiveRecordingRecord != mInProgressRecording) {
                         throw new AssertionError("In-progress recording does not match the active"
                                 + " recording. Unable to reset encoder.");
                     }
@@ -915,7 +927,7 @@
 
     @ExecutedBy("mSequentialExecutor")
     private void onInitialized() {
-        ActiveRecording recordingToStart = null;
+        RecordingRecord recordingToStart = null;
         boolean startRecordingPaused = false;
         synchronized (mLock) {
             switch (mState) {
@@ -985,6 +997,15 @@
         return mediaSpecBuilder.build();
     }
 
+    private static boolean isSameRecording(@NonNull ActiveRecording activeRecording,
+            @Nullable RecordingRecord recordingRecord) {
+        if (recordingRecord == null) {
+            return false;
+        }
+
+        return activeRecording.getRecordingId() == recordingRecord.getRecordingId();
+    }
+
     @ExecutedBy("mSequentialExecutor")
     @NonNull
     private AudioEncoderConfig composeAudioEncoderConfig(@NonNull MediaSpec mediaSpec) {
@@ -1014,8 +1035,8 @@
 
     @RequiresPermission(Manifest.permission.RECORD_AUDIO)
     @ExecutedBy("mSequentialExecutor")
-    private void setupAudioIfNeeded(@NonNull ActiveRecording activeRecording) {
-        if (!activeRecording.isAudioEnabled()) {
+    private void setupAudioIfNeeded(@NonNull RecordingRecord activeRecording) {
+        if (!activeRecording.hasAudioEnabled()) {
             // Skip if audio is not enabled for the recording.
             return;
         }
@@ -1165,14 +1186,14 @@
 
     @ExecutedBy("mSequentialExecutor")
     private void onEncoderSetupError(@Nullable Throwable cause) {
-        ActiveRecording pendingRecordingToFinalize = null;
+        RecordingRecord pendingRecordingToFinalize = null;
         synchronized (mLock) {
             switch (mState) {
                 case PENDING_PAUSED:
                     // Fall-through
                 case PENDING_RECORDING:
-                    pendingRecordingToFinalize = mPendingRecording;
-                    mPendingRecording = null;
+                    pendingRecordingToFinalize = mPendingRecordingRecord;
+                    mPendingRecordingRecord = null;
                     // Fall-through
                 case INITIALIZING:
                     setState(State.ERROR);
@@ -1299,7 +1320,7 @@
 
     @SuppressLint("MissingPermission")
     @ExecutedBy("mSequentialExecutor")
-    private void startInternal(@NonNull ActiveRecording recordingToStart) {
+    private void startInternal(@NonNull RecordingRecord recordingToStart) {
         if (mInProgressRecording != null) {
             throw new AssertionError("Attempted to start a new recording while another was in "
                     + "progress.");
@@ -1319,7 +1340,7 @@
 
         mInProgressRecording = recordingToStart;
         if (mAudioState == AudioState.INITIALIZING) {
-            setAudioState(recordingToStart.isAudioEnabled() ? AudioState.RECORDING
+            setAudioState(recordingToStart.hasAudioEnabled() ? AudioState.RECORDING
                     : AudioState.DISABLED);
         }
 
@@ -1343,7 +1364,7 @@
     }
 
     @ExecutedBy("mSequentialExecutor")
-    private void initEncoderCallbacks(@NonNull ActiveRecording recordingToStart) {
+    private void initEncoderCallbacks(@NonNull RecordingRecord recordingToStart) {
         mVideoEncoder.setEncoderCallback(new EncoderCallback() {
             @ExecutedBy("mSequentialExecutor")
             @Override
@@ -1524,7 +1545,7 @@
     }
 
     @ExecutedBy("mSequentialExecutor")
-    private void pauseInternal(@NonNull ActiveRecording recordingToPause) {
+    private void pauseInternal(@NonNull RecordingRecord recordingToPause) {
         // Only pause recording if recording is in-progress and it is not stopping.
         if (mInProgressRecording == recordingToPause && !mInProgressRecordingStopping) {
             if (isAudioEnabled()) {
@@ -1539,7 +1560,7 @@
     }
 
     @ExecutedBy("mSequentialExecutor")
-    private void resumeInternal(@NonNull ActiveRecording recordingToResume) {
+    private void resumeInternal(@NonNull RecordingRecord recordingToResume) {
         // Only resume recording if recording is in-progress and it is not stopping.
         if (mInProgressRecording == recordingToResume && !mInProgressRecordingStopping) {
             if (isAudioEnabled()) {
@@ -1555,8 +1576,7 @@
 
     @SuppressWarnings("WeakerAccess") /* synthetic accessor */
     @ExecutedBy("mSequentialExecutor")
-    void stopInternal(@NonNull ActiveRecording recordingToStop,
-            @VideoRecordError int stopError) {
+    void stopInternal(@NonNull RecordingRecord recordingToStop, @VideoRecordError int stopError) {
         // Only stop recording if recording is in-progress and it is not already stopping.
         if (mInProgressRecording == recordingToStop && !mInProgressRecordingStopping) {
             mInProgressRecordingStopping = true;
@@ -1657,7 +1677,7 @@
                         errorToSend,
                         throwable));
 
-        ActiveRecording finalizedRecording = mInProgressRecording;
+        RecordingRecord finalizedRecording = mInProgressRecording;
         mInProgressRecording = null;
         mInProgressRecordingStopping = false;
         mAudioTrackIndex = null;
@@ -1674,17 +1694,17 @@
     }
 
     @ExecutedBy("mSequentialExecutor")
-    private void onRecordingFinalized(@NonNull ActiveRecording finalizedRecording) {
+    private void onRecordingFinalized(@NonNull RecordingRecord finalizedRecording) {
         boolean needsReset = false;
         boolean startRecordingPaused = false;
-        ActiveRecording recordingToStart = null;
+        RecordingRecord recordingToStart = null;
         synchronized (mLock) {
-            if (mActiveRecording != finalizedRecording) {
+            if (mActiveRecordingRecord != finalizedRecording) {
                 throw new AssertionError("Active recording did not match finalized recording on "
                         + "finalize.");
             }
 
-            mActiveRecording = null;
+            mActiveRecordingRecord = null;
             switch (mState) {
                 case RESETTING:
                     setState(State.INITIALIZING);
@@ -1726,7 +1746,7 @@
     }
 
     @ExecutedBy("mSequentialExecutor")
-    void onInProgressRecordingInternalError(@NonNull ActiveRecording recording,
+    void onInProgressRecordingInternalError(@NonNull RecordingRecord recording,
             @VideoRecordError int error) {
         if (recording != mInProgressRecording) {
             throw new AssertionError("Internal error occurred on recording that is not the current "
@@ -1750,7 +1770,7 @@
                     // Fall-through
                 case PENDING_PAUSED:
                     // Fall-through
-                    if (recording != mActiveRecording) {
+                    if (recording != mActiveRecordingRecord) {
                         throw new AssertionError("Internal error occurred for recording but it is"
                                 + " not the active recording.");
                     }
@@ -1773,14 +1793,14 @@
     @ExecutedBy("mSequentialExecutor")
     void tryServicePendingRecording() {
         boolean startRecordingPaused = false;
-        ActiveRecording recordingToStart = null;
+        RecordingRecord recordingToStart = null;
         synchronized (mLock) {
             switch (mState) {
                 case PENDING_PAUSED:
                     startRecordingPaused = true;
                     // Fall-through
                 case PENDING_RECORDING:
-                    if (mActiveRecording != null) {
+                    if (mActiveRecordingRecord != null) {
                         // Active recording is still finalizing. Pending recording will be
                         // serviced in onRecordingFinalized().
                         break;
@@ -1820,7 +1840,7 @@
      */
     @GuardedBy("mLock")
     @NonNull
-    private ActiveRecording makePendingRecordingActiveLocked(@NonNull State state) {
+    private RecordingRecord makePendingRecordingActiveLocked(@NonNull State state) {
         boolean startRecordingPaused = false;
         if (state == State.PENDING_PAUSED) {
             startRecordingPaused = true;
@@ -1828,17 +1848,17 @@
             throw new AssertionError("makePendingRecordingActiveLocked() can only be called from "
                     + "a pending state.");
         }
-        if (mActiveRecording != null) {
+        if (mActiveRecordingRecord != null) {
             throw new AssertionError("Cannot make pending recording active because another "
                     + "recording is already active.");
         }
-        if (mPendingRecording == null) {
+        if (mPendingRecordingRecord == null) {
             throw new AssertionError("Pending recording should exist when in a PENDING"
                     + " state.");
         }
         // Swap the pending recording to the active recording and start it
-        ActiveRecording recordingToStart = mActiveRecording = mPendingRecording;
-        mPendingRecording = null;
+        RecordingRecord recordingToStart = mActiveRecordingRecord = mPendingRecordingRecord;
+        mPendingRecordingRecord = null;
         // Start recording if start() has been called before video encoder is setup.
         if (startRecordingPaused) {
             setState(State.PAUSED);
@@ -1857,7 +1877,7 @@
      * passed to this method should be the newly-made-active recording.
      */
     @ExecutedBy("mSequentialExecutor")
-    private void startActiveRecording(@NonNull ActiveRecording recordingToStart,
+    private void startActiveRecording(@NonNull RecordingRecord recordingToStart,
             boolean startRecordingPaused) {
         // Start pending recording inline since we are already on sequential executor.
         startInternal(recordingToStart);
@@ -1986,6 +2006,50 @@
         mAudioState = audioState;
     }
 
+    @AutoValue
+    abstract static class RecordingRecord {
+
+        static RecordingRecord from(@NonNull PendingRecording pendingRecording, long recordingId) {
+            return new AutoValue_Recorder_RecordingRecord(
+                    pendingRecording.getOutputOptions(),
+                    pendingRecording.getCallbackExecutor(),
+                    pendingRecording.getEventListener(),
+                    pendingRecording.isAudioEnabled(),
+                    recordingId
+            );
+        }
+
+        @NonNull
+        abstract OutputOptions getOutputOptions();
+
+        @Nullable
+        abstract Executor getCallbackExecutor();
+
+        @Nullable
+        abstract Consumer<VideoRecordEvent> getEventListener();
+
+        abstract boolean hasAudioEnabled();
+
+        abstract long getRecordingId();
+
+        /**
+         * Updates the recording status and callback to users.
+         */
+        void updateVideoRecordEvent(@NonNull VideoRecordEvent event) {
+            Preconditions.checkState(Objects.equals(event.getOutputOptions(), getOutputOptions()),
+                    "Attempted to update event listener with event from incorrect recording "
+                            + "[Recording: " + event.getOutputOptions() + ", Expected: "
+                            + getOutputOptions() + "]");
+            if (getCallbackExecutor() != null && getEventListener() != null) {
+                try {
+                    getCallbackExecutor().execute(() -> getEventListener().accept(event));
+                } catch (RejectedExecutionException e) {
+                    Logger.e(TAG, "The callback executor is invalid.", e);
+                }
+            }
+        }
+    }
+
     /**
      * Builder class for {@link Recorder} objects.
      */