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
- }
-}