Move ViewCapture On/Off controls to QuickSettings Tile.

Rather than use a feature flag for this feature, the on/off state will
be stored as a system setting and will be changed via a QuickSettings
tile.

Bug: b/264452057
Test: Verified that the new QuickSettings tile doesn't crash via normal
interactions (pressing, long-pressing, etc.). Also verified that
ViewCapture is turned on when the QuickSettings tile is in the enabled
state and is turned off when it is in the disabled state.

Change-Id: I29c5c44c6df64157c0c35a4ebc45cda74c3e1e1e
diff --git a/motiontoollib/build.gradle b/motiontoollib/build.gradle
index 2a25184..56dfdac 100644
--- a/motiontoollib/build.gradle
+++ b/motiontoollib/build.gradle
@@ -43,7 +43,6 @@
     androidTestImplementation project(':SharedTestLib')
     androidTestImplementation 'androidx.test.ext:junit:1.1.3'
     androidTestImplementation "androidx.test:rules:1.4.0"
-
 }
 
 protobuf {
diff --git a/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt
index f97bb5d..ccc0b8b 100644
--- a/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt
+++ b/motiontoollib/src/com/android/app/motiontool/MotionToolManager.kt
@@ -16,7 +16,9 @@
 
 package com.android.app.motiontool
 
+import android.os.Process
 import android.util.Log
+import android.view.Choreographer
 import android.view.View
 import android.view.WindowManagerGlobal
 import androidx.annotation.VisibleForTesting
@@ -41,10 +43,8 @@
  *
  * @see [DdmHandleMotionTool]
  */
-class MotionToolManager private constructor(
-    private val viewCapture: ViewCapture,
-    private val windowManagerGlobal: WindowManagerGlobal
-) {
+class MotionToolManager private constructor(private val windowManagerGlobal: WindowManagerGlobal) {
+    private val viewCapture: ViewCapture = SimpleViewCapture()
 
     companion object {
         private const val TAG = "MotionToolManager"
@@ -52,13 +52,8 @@
         private var INSTANCE: MotionToolManager? = null
 
         @Synchronized
-        fun getInstance(
-            viewCapture: ViewCapture,
-            windowManagerGlobal: WindowManagerGlobal
-        ): MotionToolManager {
-            return INSTANCE ?: MotionToolManager(viewCapture, windowManagerGlobal).also {
-                INSTANCE = it
-            }
+        fun getInstance(windowManagerGlobal: WindowManagerGlobal): MotionToolManager {
+            return INSTANCE ?: MotionToolManager(windowManagerGlobal).also { INSTANCE = it }
         }
     }
 
@@ -139,6 +134,10 @@
     private fun getRootView(windowId: String): View? {
         return windowManagerGlobal.getRootView(windowId)
     }
+
+    class SimpleViewCapture : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE,
+            MAIN_EXECUTOR.submit { Choreographer.getInstance() }.get(),
+            createAndStartNewLooperExecutor("MTViewCapture", Process.THREAD_PRIORITY_FOREGROUND))
 }
 
 private data class TraceMetadata(
@@ -155,4 +154,4 @@
 
 class UnknownTraceIdException(val traceId: Int) : Exception()
 
-class WindowNotFoundException(val windowId: String) : Exception()
+class WindowNotFoundException(val windowId: String) : Exception()
\ No newline at end of file
diff --git a/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt
index 5627106..176e2f3 100644
--- a/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt
+++ b/motiontoollib/tests/com/android/app/motiontool/DdmHandleMotionToolTest.kt
@@ -18,6 +18,7 @@
 
 import android.content.Intent
 import android.testing.AndroidTestingRunner
+import android.view.Choreographer
 import android.view.View
 import android.view.WindowManagerGlobal
 import androidx.test.ext.junit.rules.ActivityScenarioRule
@@ -34,7 +35,6 @@
 import com.android.app.motiontool.nano.PollTraceRequest
 import com.android.app.motiontool.nano.WindowIdentifier
 import com.android.app.motiontool.util.TestActivity
-import com.android.app.viewcapture.ViewCapture
 import com.google.protobuf.nano.MessageNano
 import junit.framework.Assert
 import junit.framework.Assert.assertEquals
@@ -46,17 +46,12 @@
 import org.junit.Test
 import org.junit.runner.RunWith
 
-
 @SmallTest
 @RunWith(AndroidTestingRunner::class)
 class DdmHandleMotionToolTest {
 
-    private val viewCaptureMemorySize = 100
-    private val viewCaptureInitPoolSize = 15
-    private val viewCapture =
-        ViewCapture.getInstance(false, viewCaptureMemorySize, viewCaptureInitPoolSize)
     private val windowManagerGlobal = WindowManagerGlobal.getInstance()
-    private val motionToolManager = MotionToolManager.getInstance(viewCapture, windowManagerGlobal)
+    private val motionToolManager = MotionToolManager.getInstance(windowManagerGlobal)
     private val ddmHandleMotionTool = DdmHandleMotionTool.getInstance(motionToolManager)
     private val CLIENT_VERSION = 1
 
@@ -74,7 +69,6 @@
     @After
     fun cleanup() {
         ddmHandleMotionTool.unregister()
-        motionToolManager.reset()
     }
 
     @Test
@@ -141,24 +135,21 @@
 
     @Test
     fun testOneOnDrawCallReturnsOneFrameResponse() {
-        var traceId = 0
-        activityScenarioRule.scenario.onActivity {
+        activityScenarioRule.scenario.onActivity { activity ->
             val beginTraceResponse = performBeginTraceRequest(getActivityViewRootId())
-            traceId = beginTraceResponse.beginTrace.traceId
-            val rootView = it.findViewById<View>(android.R.id.content)
-            rootView.invalidate()
+            val traceId = beginTraceResponse.beginTrace.traceId
+
+            Choreographer.getInstance().postFrameCallback {
+                activity.findViewById<View>(android.R.id.content).viewTreeObserver.dispatchOnDraw()
+
+                val pollTraceResponse = performPollTraceRequest(traceId)
+                assertEquals(1, pollTraceResponse.pollTrace.exportedData.frameData.size)
+
+                // Verify that frameData is only included once and is not returned again
+                val endTraceResponse = performEndTraceRequest(traceId)
+                assertEquals(0, endTraceResponse.endTrace.exportedData.frameData.size)
+            }
         }
-
-        // waits until main looper has no remaining tasks and is idle
-        activityScenarioRule.scenario.onActivity {
-            val pollTraceResponse = performPollTraceRequest(traceId)
-            assertEquals(1, pollTraceResponse.pollTrace.exportedData.frameData.size)
-
-            // Verify that frameData is only included once and is not returned again
-            val endTraceResponse = performEndTraceRequest(traceId)
-            assertEquals(0, endTraceResponse.endTrace.exportedData.frameData.size)
-        }
-
     }
 
     private fun performPollTraceRequest(requestTraceId: Int): MotionToolsResponse {
diff --git a/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt
index 02751fb..0c57b48 100644
--- a/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt
+++ b/motiontoollib/tests/com/android/app/motiontool/MotionToolManagerTest.kt
@@ -18,31 +18,25 @@
 
 import android.content.Intent
 import android.testing.AndroidTestingRunner
+import android.view.Choreographer
 import android.view.View
 import android.view.WindowManagerGlobal
 import androidx.test.ext.junit.rules.ActivityScenarioRule
 import androidx.test.filters.SmallTest
 import androidx.test.platform.app.InstrumentationRegistry
 import com.android.app.motiontool.util.TestActivity
-import com.android.app.viewcapture.ViewCapture
 import junit.framework.Assert.assertEquals
 import junit.framework.Assert.assertTrue
-import org.junit.After
 import org.junit.Rule
 import org.junit.Test
 import org.junit.runner.RunWith
 
-
 @SmallTest
 @RunWith(AndroidTestingRunner::class)
 class MotionToolManagerTest {
 
     private val windowManagerGlobal = WindowManagerGlobal.getInstance()
-    private val viewCaptureMemorySize = 100
-    private val viewCaptureInitPoolSize = 15
-    private val viewCapture =
-        ViewCapture.getInstance(false, viewCaptureMemorySize, viewCaptureInitPoolSize)
-    private val motionToolManager = MotionToolManager.getInstance(viewCapture, windowManagerGlobal)
+    private val motionToolManager = MotionToolManager.getInstance(windowManagerGlobal)
 
     private val activityIntent =
         Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java)
@@ -50,11 +44,6 @@
     @get:Rule
     val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
 
-    @After
-    fun cleanup() {
-        motionToolManager.reset()
-    }
-
     @Test(expected = UnknownTraceIdException::class)
     fun testEndTraceThrowsWithoutPrecedingBeginTrace() {
         motionToolManager.endTrace(0)
@@ -93,23 +82,19 @@
 
     @Test
     fun testOneOnDrawCallReturnsOneFrameResponse() {
-        var traceId = 0
-        activityScenarioRule.scenario.onActivity {
-            traceId = motionToolManager.beginTrace(getActivityViewRootId())
-            val rootView = it.findViewById<View>(android.R.id.content)
-            rootView.invalidate()
+        activityScenarioRule.scenario.onActivity { activity ->
+            val traceId = motionToolManager.beginTrace(getActivityViewRootId())
+            Choreographer.getInstance().postFrameCallback {
+                activity.findViewById<View>(android.R.id.content).viewTreeObserver.dispatchOnDraw()
+
+                val polledExportedData = motionToolManager.pollTrace(traceId)
+                assertEquals(1, polledExportedData.frameData.size)
+
+                // Verify that frameData is only included once and is not returned again
+                val endExportedData = motionToolManager.endTrace(traceId)
+                assertEquals(0, endExportedData.frameData.size)
+            }
         }
-
-        // waits until main looper has no remaining tasks and is idle
-        activityScenarioRule.scenario.onActivity {
-            val polledExportedData = motionToolManager.pollTrace(traceId)
-            assertEquals(1, polledExportedData.frameData.size)
-
-            // Verify that frameData is only included once and is not returned again
-            val endExportedData = motionToolManager.endTrace(traceId)
-            assertEquals(0, endExportedData.frameData.size)
-        }
-
     }
 
     private fun getActivityViewRootId(): String {
diff --git a/viewcapturelib/AndroidManifest.xml b/viewcapturelib/AndroidManifest.xml
index e5127c6..1da8129 100644
--- a/viewcapturelib/AndroidManifest.xml
+++ b/viewcapturelib/AndroidManifest.xml
@@ -16,5 +16,8 @@
 -->
 
 <manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    xmlns:tools="http://schemas.android.com/tools"
     package="com.android.app.viewcapture">
+    <uses-permission android:name="android.permission.WRITE_SECURE_SETTINGS"
+        tools:ignore="ProtectedPermissions" />
 </manifest>
diff --git a/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java
index 1a18193..e3450f6 100644
--- a/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java
+++ b/viewcapturelib/src/com/android/app/viewcapture/LooperExecutor.java
@@ -28,7 +28,7 @@
 /**
  * Implementation of {@link Executor} which executes on a provided looper.
  */
-class LooperExecutor implements Executor {
+public class LooperExecutor implements Executor {
 
     private final Handler mHandler;
 
diff --git a/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt
new file mode 100644
index 0000000..ff9a3e0
--- /dev/null
+++ b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt
@@ -0,0 +1,82 @@
+/*
+ * Copyright (C) 2023 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 com.android.app.viewcapture
+
+import android.content.Context
+import android.database.ContentObserver
+import android.os.Handler
+import android.os.Looper
+import android.os.Process
+import android.provider.Settings
+import android.view.Choreographer
+import androidx.annotation.AnyThread
+import androidx.annotation.VisibleForTesting
+import java.util.concurrent.Executor
+
+/**
+ * ViewCapture that listens to system updates and enables / disables attached ViewCapture
+ * WindowListeners accordingly. The Settings toggle is currently controlled by the Winscope
+ * developer tile in the System developer options.
+ */
+class SettingsAwareViewCapture
+@VisibleForTesting
+internal constructor(private val context: Context, choreographer: Choreographer, executor: Executor)
+    : ViewCapture(DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE, choreographer, executor) {
+
+    init {
+        enableOrDisableWindowListeners()
+        context.contentResolver.registerContentObserver(
+                Settings.Global.getUriFor(VIEW_CAPTURE_ENABLED),
+                false,
+                object : ContentObserver(Handler()) {
+                    override fun onChange(selfChange: Boolean) {
+                        enableOrDisableWindowListeners()
+                    }
+                })
+    }
+
+    @AnyThread
+    private fun enableOrDisableWindowListeners() {
+        mBgExecutor.execute {
+            val isEnabled = Settings.Global.getInt(context.contentResolver, VIEW_CAPTURE_ENABLED,
+                    0) != 0
+            MAIN_EXECUTOR.execute {
+                enableOrDisableWindowListeners(isEnabled)
+            }
+        }
+    }
+
+    companion object {
+        @VisibleForTesting internal const val VIEW_CAPTURE_ENABLED = "view_capture_enabled"
+
+        private var INSTANCE: ViewCapture? = null
+
+        @JvmStatic
+        fun getInstance(context: Context): ViewCapture = when {
+            INSTANCE != null -> INSTANCE!!
+            Looper.myLooper() == Looper.getMainLooper() -> SettingsAwareViewCapture(context,
+                    Choreographer.getInstance(), createAndStartNewLooperExecutor("SAViewCapture",
+                    Process.THREAD_PRIORITY_FOREGROUND)).also { INSTANCE = it }
+            else -> try {
+                MAIN_EXECUTOR.submit { getInstance(context) }.get()
+            } catch (e: Exception) {
+                throw e
+            }
+        }
+
+    }
+}
\ No newline at end of file
diff --git a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
index 70c58cb..abc265c 100644
--- a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
+++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java
@@ -35,11 +35,9 @@
 import android.view.ViewGroup;
 import android.view.ViewTreeObserver;
 import android.view.Window;
-import android.os.Process;
 
 import androidx.annotation.Nullable;
 import androidx.annotation.UiThread;
-import androidx.annotation.VisibleForTesting;
 import androidx.annotation.WorkerThread;
 
 import com.android.app.viewcapture.data.nano.ExportedData;
@@ -54,9 +52,8 @@
 import java.io.PrintWriter;
 import java.util.ArrayList;
 import java.util.List;
-import java.util.concurrent.ExecutionException;
-import java.util.concurrent.Executor;
 import java.util.Optional;
+import java.util.concurrent.Executor;
 import java.util.concurrent.FutureTask;
 import java.util.function.Consumer;
 import java.util.zip.GZIPOutputStream;
@@ -64,7 +61,7 @@
 /**
  * Utility class for capturing view data every frame
  */
-public class ViewCapture {
+public abstract class ViewCapture {
 
     private static final String TAG = "ViewCapture";
 
@@ -74,55 +71,31 @@
 
     // Number of frames to keep in memory
     private final int mMemorySize;
-    private static final int DEFAULT_MEMORY_SIZE = 2000;
+    protected static final int DEFAULT_MEMORY_SIZE = 2000;
     // Initial size of the reference pool. This is at least be 5 * total number of views in
     // Launcher. This allows the first free frames avoid object allocation during view capture.
-    private static final int DEFAULT_INIT_POOL_SIZE = 300;
+    protected static final int DEFAULT_INIT_POOL_SIZE = 300;
 
-    private static ViewCapture INSTANCE;
     public static final LooperExecutor MAIN_EXECUTOR = new LooperExecutor(Looper.getMainLooper());
 
-    public static ViewCapture getInstance() {
-        return getInstance(true, DEFAULT_MEMORY_SIZE, DEFAULT_INIT_POOL_SIZE);
-    }
-
-    @VisibleForTesting
-    public static ViewCapture getInstance(boolean offloadToBackgroundThread, int memorySize,
-            int initPoolSize) {
-        if (INSTANCE == null) {
-            if (Looper.myLooper() == Looper.getMainLooper()) {
-                INSTANCE = new ViewCapture(offloadToBackgroundThread, memorySize, initPoolSize);
-            } else {
-                try {
-                    return MAIN_EXECUTOR.submit(() ->
-                            getInstance(offloadToBackgroundThread, memorySize, initPoolSize)).get();
-                } catch (InterruptedException | ExecutionException e) {
-                    throw new RuntimeException(e);
-                }
-            }
-        }
-        return INSTANCE;
-    }
-
     private final List<WindowListener> mListeners = new ArrayList<>();
 
-    private final Executor mExecutor;
+    protected final Executor mBgExecutor;
+    private final Choreographer mChoreographer;
 
     // Pool used for capturing view tree on the UI thread.
     private ViewRef mPool = new ViewRef();
+    private boolean mIsEnabled = true;
 
-    private ViewCapture(boolean offloadToBackgroundThread, int memorySize, int initPoolSize) {
+    protected ViewCapture(int memorySize, int initPoolSize, Choreographer choreographer,
+            Executor bgExecutor) {
         mMemorySize = memorySize;
-        if (offloadToBackgroundThread) {
-            mExecutor = createAndStartNewLooperExecutor("ViewCapture",
-                    Process.THREAD_PRIORITY_FOREGROUND);
-        } else {
-            mExecutor = MAIN_EXECUTOR;
-        }
-        mExecutor.execute(() -> initPool(initPoolSize));
+        mChoreographer = choreographer;
+        mBgExecutor = bgExecutor;
+        mBgExecutor.execute(() -> initPool(initPoolSize));
     }
 
-    private static LooperExecutor createAndStartNewLooperExecutor(String name, int priority) {
+    public static LooperExecutor createAndStartNewLooperExecutor(String name, int priority) {
         HandlerThread thread = new HandlerThread(name, priority);
         thread.start();
         return new LooperExecutor(thread.getLooper());
@@ -158,18 +131,27 @@
     }
 
     /**
-     * Attaches the ViewCapture to the provided window and returns a handle to detach the listener
+     * Attaches the ViewCapture to the provided window and returns a handle to detach the listener.
+     * Verifies that ViewCapture is enabled before actually attaching an onDrawListener.
      */
     public SafeCloseable startCapture(View view, String name) {
         WindowListener listener = new WindowListener(view, name);
-        mExecutor.execute(() -> MAIN_EXECUTOR.execute(listener::attachToRoot));
+        if (mIsEnabled) MAIN_EXECUTOR.execute(listener::attachToRoot);
         mListeners.add(listener);
         return () -> {
             mListeners.remove(listener);
-            listener.destroy();
+            listener.detachFromRoot();
         };
     }
 
+    @UiThread
+    protected void enableOrDisableWindowListeners(boolean isEnabled) {
+        mIsEnabled = isEnabled;
+        mListeners.forEach(WindowListener::detachFromRoot);
+        if (mIsEnabled) mListeners.forEach(WindowListener::attachToRoot);
+    }
+
+
     /**
      * Dumps all the active view captures
      */
@@ -181,7 +163,7 @@
                 .map(l -> {
                     FutureTask<ExportedData> task =
                             new FutureTask<ExportedData>(() -> l.dumpToProto(idProvider));
-                    mExecutor.execute(task);
+                    mBgExecutor.execute(task);
                     return Pair.create(l.name, task);
                 })
                 .collect(toList());
@@ -216,7 +198,7 @@
                 .map(l -> {
                     FutureTask<ExportedData> task =
                             new FutureTask<ExportedData>(() -> l.dumpToProto(idProvider));
-                    mExecutor.execute(task);
+                    mBgExecutor.execute(task);
                     return task;
                 })
                 .findFirst();
@@ -272,16 +254,10 @@
         private final long[] mFrameTimesNanosBg = new long[mMemorySize];
         private final ViewPropertyRef[] mNodesBg = new ViewPropertyRef[mMemorySize];
 
-        private boolean mDestroyed = false;
+        private boolean mIsActive = true;
         private final Consumer<ViewRef> mCaptureCallback = this::captureViewPropertiesBg;
-        private Choreographer mChoreographer;
 
         WindowListener(View view, String name) {
-            try {
-                mChoreographer = MAIN_EXECUTOR.submit(Choreographer::getInstance).get();
-            } catch (InterruptedException | ExecutionException e) {
-                throw new RuntimeException(e);
-            }
             mRoot = view;
             this.name = name;
         }
@@ -300,7 +276,7 @@
             if (captured != null) {
                 captured.callback = mCaptureCallback;
                 captured.choreographerTimeNanos = mChoreographer.getFrameTimeNanos();
-                mExecutor.execute(captured);
+                mBgExecutor.execute(captured);
             }
             mIsFirstFrame = false;
             Trace.endSection();
@@ -392,14 +368,15 @@
         }
 
         void attachToRoot() {
+            mIsActive = true;
             if (mRoot.isAttachedToWindow()) {
-                mRoot.getViewTreeObserver().addOnDrawListener(this);
+                safelyEnableOnDrawListener();
             } else {
                 mRoot.addOnAttachStateChangeListener(new View.OnAttachStateChangeListener() {
                     @Override
                     public void onViewAttachedToWindow(View v) {
-                        if (!mDestroyed) {
-                            mRoot.getViewTreeObserver().addOnDrawListener(WindowListener.this);
+                        if (mIsActive) {
+                            safelyEnableOnDrawListener();
                         }
                         mRoot.removeOnAttachStateChangeListener(this);
                     }
@@ -411,9 +388,14 @@
             }
         }
 
-        void destroy() {
+        void detachFromRoot() {
+            mIsActive = false;
             mRoot.getViewTreeObserver().removeOnDrawListener(this);
-            mDestroyed = true;
+        }
+
+        private void safelyEnableOnDrawListener() {
+            mRoot.getViewTreeObserver().removeOnDrawListener(this);
+            mRoot.getViewTreeObserver().addOnDrawListener(this);
         }
 
         @WorkerThread
diff --git a/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt
new file mode 100644
index 0000000..c931eeb
--- /dev/null
+++ b/viewcapturelib/tests/com/android/app/viewcapture/SettingsAwareViewCaptureTest.kt
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2023 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 com.android.app.viewcapture
+
+import android.content.Context
+import android.content.Intent
+import android.media.permission.SafeCloseable
+import android.provider.Settings
+import android.testing.AndroidTestingRunner
+import android.view.Choreographer
+import android.view.View
+import androidx.test.ext.junit.rules.ActivityScenarioRule
+import androidx.test.filters.SmallTest
+import androidx.test.platform.app.InstrumentationRegistry
+import com.android.app.viewcapture.SettingsAwareViewCapture.Companion.VIEW_CAPTURE_ENABLED
+import com.android.app.viewcapture.ViewCapture.MAIN_EXECUTOR
+import junit.framework.Assert.assertEquals
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@SmallTest
+@RunWith(AndroidTestingRunner::class)
+class SettingsAwareViewCaptureTest {
+    private val context: Context = InstrumentationRegistry.getInstrumentation().context
+    private val activityIntent = Intent(context, TestActivity::class.java)
+
+    @get:Rule val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
+
+    @Test
+    fun do_not_capture_view_hierarchies_if_setting_is_disabled() {
+        Settings.Global.putInt(context.contentResolver, VIEW_CAPTURE_ENABLED, 0)
+
+        activityScenarioRule.scenario.onActivity { activity ->
+            val viewCapture: ViewCapture =
+                SettingsAwareViewCapture(context, Choreographer.getInstance(), MAIN_EXECUTOR)
+            val rootView: View = activity.findViewById(android.R.id.content)
+
+            val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId")
+            Choreographer.getInstance().postFrameCallback {
+                rootView.viewTreeObserver.dispatchOnDraw()
+
+                assertEquals(0, viewCapture.getDumpTask(
+                        activity.findViewById(android.R.id.content)).get().get().frameData.size)
+                closeable.close()
+            }
+        }
+    }
+
+    @Test
+    fun capture_view_hierarchies_if_setting_is_enabled() {
+        Settings.Global.putInt(context.contentResolver, VIEW_CAPTURE_ENABLED, 1)
+
+        activityScenarioRule.scenario.onActivity { activity ->
+            val viewCapture: ViewCapture =
+                SettingsAwareViewCapture(context, Choreographer.getInstance(), MAIN_EXECUTOR)
+            val rootView: View = activity.findViewById(android.R.id.content)
+
+            val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId")
+            Choreographer.getInstance().postFrameCallback {
+                rootView.viewTreeObserver.dispatchOnDraw()
+
+                assertEquals(1, viewCapture.getDumpTask(activity.findViewById(
+                        android.R.id.content)).get().get().frameData.size)
+
+                closeable.close()
+            }
+        }
+    }
+
+    @Test
+    fun getInstance_calledTwiceInARow_returnsSameObject() {
+        assertEquals(
+            SettingsAwareViewCapture.getInstance(context).hashCode(),
+            SettingsAwareViewCapture.getInstance(context).hashCode()
+        )
+    }
+}
diff --git a/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt
new file mode 100644
index 0000000..749327e
--- /dev/null
+++ b/viewcapturelib/tests/com/android/app/viewcapture/TestActivity.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (C) 2023 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 com.android.app.viewcapture
+
+import android.app.Activity
+import android.os.Bundle
+import android.widget.LinearLayout
+import android.widget.TextView
+
+/**
+ * Activity with the content set to a [LinearLayout] with [TextView] children.
+ */
+class TestActivity : Activity() {
+
+    companion object {
+        const val TEXT_VIEW_COUNT = 1000
+    }
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(createContentView())
+    }
+
+    private fun createContentView(): LinearLayout {
+        val root = LinearLayout(this)
+        for (i in 0 until TEXT_VIEW_COUNT) {
+            root.addView(TextView(this))
+        }
+        return root
+    }
+}
\ No newline at end of file
diff --git a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt
index d390c72..56840ca 100644
--- a/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt
+++ b/viewcapturelib/tests/com/android/app/viewcapture/ViewCaptureTest.kt
@@ -16,11 +16,10 @@
 
 package com.android.app.viewcapture
 
-import android.app.Activity
 import android.content.Intent
 import android.media.permission.SafeCloseable
-import android.os.Bundle
 import android.testing.AndroidTestingRunner
+import android.view.Choreographer
 import android.view.View
 import android.widget.LinearLayout
 import android.widget.TextView
@@ -38,106 +37,80 @@
 @RunWith(AndroidTestingRunner::class)
 class ViewCaptureTest {
 
-    private val viewCaptureMemorySize = 100
-    private val viewCaptureInitPoolSize = 15
-    private val viewCapture =
-        ViewCapture.getInstance(false, viewCaptureMemorySize, viewCaptureInitPoolSize)
+    private val memorySize = 100
+    private val initPoolSize = 15
+    private val viewCapture by lazy {
+        object :
+            ViewCapture(memorySize, initPoolSize, Choreographer.getInstance(), MAIN_EXECUTOR) {}
+    }
 
     private val activityIntent =
         Intent(InstrumentationRegistry.getInstrumentation().context, TestActivity::class.java)
 
-    @get:Rule
-    val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
-
+    @get:Rule val activityScenarioRule = ActivityScenarioRule<TestActivity>(activityIntent)
 
     @Test
     fun testViewCaptureDumpsOneFrameAfterInvalidate() {
-        val closeable = startViewCaptureAndInvalidateNTimes(1)
+        activityScenarioRule.scenario.onActivity { activity ->
+            Choreographer.getInstance().postFrameCallback {
+                val closeable = startViewCaptureAndInvalidateNTimes(1, activity)
+                val rootView = activity.findViewById<View>(android.R.id.content)
+                val exportedData = viewCapture.getDumpTask(rootView).get().get()
 
-        // waits until main looper has no remaining tasks and is idle
-        activityScenarioRule.scenario.onActivity {
-            val rootView = it.findViewById<View>(android.R.id.content)
-            val exportedData = viewCapture.getDumpTask(rootView).get().get()
-
-            assertEquals(1, exportedData.frameData.size)
-            verifyTestActivityViewHierarchy(exportedData)
+                assertEquals(1, exportedData.frameData.size)
+                verifyTestActivityViewHierarchy(exportedData)
+                closeable.close()
+            }
         }
-        closeable?.close()
     }
 
     @Test
     fun testViewCaptureDumpsCorrectlyAfterRecyclingStarted() {
-        val closeable = startViewCaptureAndInvalidateNTimes(viewCaptureMemorySize + 5)
+        activityScenarioRule.scenario.onActivity { activity ->
+            Choreographer.getInstance().postFrameCallback {
+                val closeable = startViewCaptureAndInvalidateNTimes(memorySize + 5, activity)
+                val rootView = activity.findViewById<View>(android.R.id.content)
+                val exportedData = viewCapture.getDumpTask(rootView).get().get()
 
-        // waits until main looper has no remaining tasks and is idle
-        activityScenarioRule.scenario.onActivity {
-            val rootView = it.findViewById<View>(android.R.id.content)
-            val exportedData = viewCapture.getDumpTask(rootView).get().get()
-
-            // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only
-            // [viewCaptureMemorySize] frames are exported, although the view is invalidated
-            // [viewCaptureMemorySize + 5] times
-            assertEquals(viewCaptureMemorySize, exportedData.frameData.size)
-            verifyTestActivityViewHierarchy(exportedData)
+                // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only
+                // [viewCaptureMemorySize] frames are exported, although the view is invalidated
+                // [viewCaptureMemorySize + 5] times
+                assertEquals(memorySize, exportedData.frameData.size)
+                verifyTestActivityViewHierarchy(exportedData)
+                closeable.close()
+            }
         }
-        closeable?.close()
     }
 
-    private fun startViewCaptureAndInvalidateNTimes(n: Int): SafeCloseable? {
-        var closeable: SafeCloseable? = null
-        activityScenarioRule.scenario.onActivity {
-            val rootView = it.findViewById<View>(android.R.id.content)
-            closeable = viewCapture.startCapture(rootView, "rootViewId")
-            invalidateView(rootView, times = n)
-        }
+    private fun startViewCaptureAndInvalidateNTimes(n: Int, activity: TestActivity): SafeCloseable {
+        val rootView: View = activity.findViewById(android.R.id.content)
+        val closeable: SafeCloseable = viewCapture.startCapture(rootView, "rootViewId")
+        dispatchOnDraw(rootView, times = n)
         return closeable
     }
 
-    private fun invalidateView(view: View, times: Int) {
-        if (times <= 0) return
-        view.post {
-            view.invalidate()
-            invalidateView(view, times - 1)
+    private fun dispatchOnDraw(view: View, times: Int) {
+        if (times > 0) {
+            view.viewTreeObserver.dispatchOnDraw()
+            dispatchOnDraw(view, times - 1)
         }
     }
 
     private fun verifyTestActivityViewHierarchy(exportedData: ExportedData) {
-        val classnames = exportedData.classname
         for (frame in exportedData.frameData) {
-            val root = frame.node // FrameLayout (android.R.id.content)
-            val testActivityRoot = root.children.first() // LinearLayout (set by setContentView())
+            val testActivityRoot =
+                frame.node // FrameLayout (android.R.id.content)
+                    .children
+                    .first() // LinearLayout (set by setContentView())
             assertEquals(TEXT_VIEW_COUNT, testActivityRoot.children.size)
             assertEquals(
                 LinearLayout::class.qualifiedName,
-                classnames[testActivityRoot.classnameIndex]
+                exportedData.classname[testActivityRoot.classnameIndex]
             )
             assertEquals(
                 TextView::class.qualifiedName,
-                classnames[testActivityRoot.children.first().classnameIndex]
+                exportedData.classname[testActivityRoot.children.first().classnameIndex]
             )
         }
     }
 }
-
-/**
- * Activity with the content set to a [LinearLayout] with [TextView] children.
- */
-class TestActivity : Activity() {
-
-    companion object {
-        const val TEXT_VIEW_COUNT = 1000
-    }
-
-    override fun onCreate(savedInstanceState: Bundle?) {
-        super.onCreate(savedInstanceState)
-        setContentView(createContentView())
-    }
-
-    private fun createContentView(): LinearLayout {
-        val root = LinearLayout(this)
-        for (i in 0 until TEXT_VIEW_COUNT) {
-            root.addView(TextView(this))
-        }
-        return root
-    }
-}