diff options
Diffstat (limited to 'viewcapturelib')
9 files changed, 367 insertions, 182 deletions
diff --git a/viewcapturelib/Android.bp b/viewcapturelib/Android.bp index d5700e5..33da2dd 100644 --- a/viewcapturelib/Android.bp +++ b/viewcapturelib/Android.bp @@ -20,12 +20,12 @@ java_library { name: "view_capture_proto", srcs: ["src/com/android/app/viewcapture/proto/*.proto"], proto: { - type: "nano", + type: "lite", local_include_dirs:[ "src/com/android/app/viewcapture/proto" ], }, - static_libs: ["libprotobuf-java-nano"], + static_libs: ["libprotobuf-java-lite"], java_version: "1.8", } 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/build.gradle b/viewcapturelib/build.gradle index 7f56819..3f40ad6 100644 --- a/viewcapturelib/build.gradle +++ b/viewcapturelib/build.gradle @@ -35,7 +35,7 @@ android { dependencies { implementation "androidx.core:core:1.9.0" - implementation PROTOBUF_DEPENDENCY + implementation "com.google.protobuf:protobuf-lite:${protobuf_version}" androidTestImplementation project(':SharedTestLib') androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation "androidx.test:rules:1.4.0" @@ -45,15 +45,21 @@ protobuf { // Configure the protoc executable protoc { artifact = "com.google.protobuf:protoc:${protobuf_version}${PROTO_ARCH_SUFFIX}" - generateProtoTasks { - all().each { task -> - task.builtins { - remove java - javanano { - option "enum_style=c" - } - } + } + plugins { + javalite { + // The codegen for lite comes as a separate artifact + artifact = "com.google.protobuf:protoc-gen-javalite:${protobuf_version}${PROTO_ARCH_SUFFIX}" + } + } + generateProtoTasks { + all().each { task -> + task.builtins { + remove java + } + task.plugins { + javalite { } } } } -} +}
\ No newline at end of file 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 @@ import java.util.concurrent.RunnableFuture; /** * 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..c84d4d5 --- /dev/null +++ b/viewcapturelib/src/com/android/app/viewcapture/SettingsAwareViewCapture.kt @@ -0,0 +1,83 @@ +/* + * 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.applicationContext, 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..fcd7ad8 100644 --- a/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java +++ b/viewcapturelib/src/com/android/app/viewcapture/ViewCapture.java @@ -35,18 +35,14 @@ import android.view.View; 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; -import com.android.app.viewcapture.data.nano.FrameData; -import com.android.app.viewcapture.data.nano.ViewNode; - -import com.google.protobuf.nano.MessageNano; +import com.android.app.viewcapture.data.ExportedData; +import com.android.app.viewcapture.data.FrameData; +import com.android.app.viewcapture.data.ViewNode; import java.io.FileDescriptor; import java.io.FileOutputStream; @@ -54,9 +50,8 @@ import java.io.OutputStream; 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 +59,7 @@ import java.util.zip.GZIPOutputStream; /** * Utility class for capturing view data every frame */ -public class ViewCapture { +public abstract class ViewCapture { private static final String TAG = "ViewCapture"; @@ -74,55 +69,31 @@ public class ViewCapture { // 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,22 +129,34 @@ public class ViewCapture { } /** - * 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 */ public void dump(PrintWriter writer, FileDescriptor out, Context context) { + if (!mIsEnabled) { + return; + } ViewIdProvider idProvider = new ViewIdProvider(context.getResources()); // Collect all the tasks first so that all the tasks are posted on the executor @@ -181,7 +164,7 @@ public class ViewCapture { .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()); @@ -196,7 +179,7 @@ public class ViewCapture { ExportedData data = pair.second.get(); OutputStream encodedOS = new GZIPOutputStream(new Base64OutputStream(os, Base64.NO_CLOSE | Base64.NO_PADDING | Base64.NO_WRAP)); - encodedOS.write(MessageNano.toByteArray(data)); + data.writeTo(encodedOS); encodedOS.close(); os.flush(); } catch (Exception e) { @@ -216,7 +199,7 @@ public class ViewCapture { .map(l -> { FutureTask<ExportedData> task = new FutureTask<ExportedData>(() -> l.dumpToProto(idProvider)); - mExecutor.execute(task); + mBgExecutor.execute(task); return task; }) .findFirst(); @@ -272,16 +255,10 @@ public class ViewCapture { 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 +277,7 @@ public class ViewCapture { if (captured != null) { captured.callback = mCaptureCallback; captured.choreographerTimeNanos = mChoreographer.getFrameTimeNanos(); - mExecutor.execute(captured); + mBgExecutor.execute(captured); } mIsFirstFrame = false; Trace.endSection(); @@ -392,14 +369,15 @@ public class ViewCapture { } 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,29 +389,34 @@ public class ViewCapture { } } - void destroy() { + void detachFromRoot() { + mIsActive = false; mRoot.getViewTreeObserver().removeOnDrawListener(this); - mDestroyed = true; + } + + private void safelyEnableOnDrawListener() { + mRoot.getViewTreeObserver().removeOnDrawListener(this); + mRoot.getViewTreeObserver().addOnDrawListener(this); } @WorkerThread private ExportedData dumpToProto(ViewIdProvider idProvider) { int size = (mNodesBg[mMemorySize - 1] == null) ? mFrameIndexBg + 1 : mMemorySize; - ExportedData exportedData = new ExportedData(); - exportedData.frameData = new FrameData[size]; + ExportedData.Builder exportedDataBuilder = ExportedData.newBuilder(); ArrayList<Class> classList = new ArrayList<>(); for (int i = size - 1; i >= 0; i--) { int index = (mMemorySize + mFrameIndexBg - i) % mMemorySize; - ViewNode node = new ViewNode(); - mNodesBg[index].toProto(idProvider, classList, node); - FrameData frameData = new FrameData(); - frameData.node = node; - frameData.timestamp = mFrameTimesNanosBg[index]; - exportedData.frameData[size - i - 1] = frameData; + ViewNode.Builder nodeBuilder = ViewNode.newBuilder(); + mNodesBg[index].toProto(idProvider, classList, nodeBuilder); + FrameData.Builder frameDataBuilder = FrameData.newBuilder() + .setNode(nodeBuilder) + .setTimestamp(mFrameTimesNanosBg[index]); + exportedDataBuilder.addFrameData(frameDataBuilder); } - exportedData.classname = classList.stream().map(Class::getName).toArray(String[]::new); - return exportedData; + return exportedDataBuilder + .addAllClassname(classList.stream().map(Class::getName).collect(toList())) + .build(); } private ViewRef captureViewTree(View view, ViewRef start) { @@ -518,35 +501,35 @@ public class ViewCapture { * at the end of the iteration. */ public ViewPropertyRef toProto(ViewIdProvider idProvider, ArrayList<Class> classList, - ViewNode viewNode) { + ViewNode.Builder viewNode) { int classnameIndex = classList.indexOf(clazz); if (classnameIndex < 0) { classnameIndex = classList.size(); classList.add(clazz); } - viewNode.classnameIndex = classnameIndex; - viewNode.hashcode = hashCode; - viewNode.id = idProvider.getName(id); - viewNode.left = left; - viewNode.top = top; - viewNode.width = right - left; - viewNode.height = bottom - top; - viewNode.translationX = translateX; - viewNode.translationY = translateY; - viewNode.scaleX = scaleX; - viewNode.scaleY = scaleY; - viewNode.alpha = alpha; - viewNode.visibility = visibility; - viewNode.willNotDraw = willNotDraw; - viewNode.elevation = elevation; - viewNode.clipChildren = clipChildren; + + viewNode.setClassnameIndex(classnameIndex) + .setHashcode(hashCode) + .setId(idProvider.getName(id)) + .setLeft(left) + .setTop(top) + .setWidth(right - left) + .setHeight(bottom - top) + .setTranslationX(translateX) + .setTranslationY(translateY) + .setScaleX(scaleX) + .setScaleY(scaleY) + .setAlpha(alpha) + .setVisibility(visibility) + .setWillNotDraw(willNotDraw) + .setElevation(elevation) + .setClipChildren(clipChildren); ViewPropertyRef result = next; - viewNode.children = new ViewNode[childCount]; for (int i = 0; (i < childCount) && (result != null); i++) { - ViewNode childViewNode = new ViewNode(); + ViewNode.Builder childViewNode = ViewNode.newBuilder(); result = result.toProto(idProvider, classList, childViewNode); - viewNode.children[i] = childViewNode; + viewNode.addChildren(childViewNode); } return result; } 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..e08b549 --- /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().frameDataList.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().frameDataList.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..b0fcca1 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 @@ -28,7 +27,7 @@ import androidx.test.ext.junit.rules.ActivityScenarioRule import androidx.test.filters.SmallTest import androidx.test.platform.app.InstrumentationRegistry import com.android.app.viewcapture.TestActivity.Companion.TEXT_VIEW_COUNT -import com.android.app.viewcapture.data.nano.ExportedData +import com.android.app.viewcapture.data.ExportedData import junit.framework.Assert.assertEquals import org.junit.Rule import org.junit.Test @@ -38,106 +37,80 @@ import org.junit.runner.RunWith @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) - - // 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) + 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() + + assertEquals(1, exportedData.frameDataList.size) + verifyTestActivityViewHierarchy(exportedData) + closeable.close() + } } - closeable?.close() } @Test fun testViewCaptureDumpsCorrectlyAfterRecyclingStarted() { - val closeable = startViewCaptureAndInvalidateNTimes(viewCaptureMemorySize + 5) - - // 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) + 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() + + // since ViewCapture MEMORY_SIZE is [viewCaptureMemorySize], only + // [viewCaptureMemorySize] frames are exported, although the view is invalidated + // [viewCaptureMemorySize + 5] times + assertEquals(memorySize, exportedData.frameDataList.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()) - assertEquals(TEXT_VIEW_COUNT, testActivityRoot.children.size) + for (frame in exportedData.frameDataList) { + val testActivityRoot = + frame.node // FrameLayout (android.R.id.content) + .childrenList + .first() // LinearLayout (set by setContentView()) + assertEquals(TEXT_VIEW_COUNT, testActivityRoot.childrenList.size) assertEquals( LinearLayout::class.qualifiedName, - classnames[testActivityRoot.classnameIndex] + exportedData.getClassname(testActivityRoot.classnameIndex) ) assertEquals( TextView::class.qualifiedName, - classnames[testActivityRoot.children.first().classnameIndex] + exportedData.getClassname(testActivityRoot.childrenList.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 - } -} |
