View capture analyzer tree walker + Alpha jump detector V1

The analyzer is an utility that can be used from the test, and not only.
If an anomaly (such as a sudden jump of view’s coordinates between frames) is detected, the utility will throw an error.

The CL includes an early version of detectors for one of the anomalies we plan to detect: alpha jump (included), flash, position jump.

The analysis is currently not invoked from tests, we simply are adding the code.

Alpha jump detector:
The included alpha jump detector contains a long (but still incomplete) list of views for which we ignore alpha jumps. This list should go away after view capture data begins supporting fields like “ignore alpha jumps for this view”.

We currently detect only alpha jumps by 100%, i.e. when the view switches from completely opaque state to completely invisible or vice versa.

ScrimView treatment:
Since we don’t know at the moment whether ScrimView is opaque, we currently ignore all activity under it.

Bug: 286251603
Flag: N/A
Test: manually on Launcher hacked to invoke this analyzer
Change-Id: Ic86aff561a0c273afd7714d8287cb724bb2aecaf
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
new file mode 100644
index 0000000..e40fb79
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
@@ -0,0 +1,91 @@
+/*
+ * 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.launcher3.util.viewcapture_analysis;
+
+import static com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.diagPathFromRoot;
+
+import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode;
+import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnomalyDetector;
+
+import java.util.Collection;
+import java.util.Set;
+
+/**
+ * Anomaly detector that triggers an error when alpha of a view changes too rapidly.
+ * Invisible views are treated as if they had zero alpha.
+ */
+final class AlphaJumpDetector extends AnomalyDetector {
+    // Paths of nodes that are excluded from analysis.
+    private static final Collection<String> PATHS_TO_IGNORE = Set.of(
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|SearchContainerView:id/apps_view|SearchRecyclerView:id"
+                    + "/search_results_list_view|SearchResultSmallIconRow",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|SearchContainerView:id/apps_view|SearchRecyclerView:id"
+                    + "/search_results_list_view|SearchResultIcon",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|LauncherRecentsView:id/overview_panel|TaskView",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|WidgetsFullSheet|SpringRelativeLayout:id/container"
+                    + "|WidgetsRecyclerView:id/primary_widgets_list_view|WidgetsListHeader:id"
+                    + "/widgets_list_header",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|WidgetsFullSheet|SpringRelativeLayout:id/container"
+                    + "|WidgetsRecyclerView:id/primary_widgets_list_view"
+                    + "|StickyHeaderLayout$EmptySpaceView",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|SearchContainerView:id/apps_view|AllAppsRecyclerView:id"
+                    + "/apps_list_view|BubbleTextView:id/icon",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|LauncherRecentsView:id/overview_panel|ClearAllButton:id"
+                    + "/clear_all",
+            "DecorView|LinearLayout|FrameLayout:id/content|LauncherRootView:id/launcher|DragLayer"
+                    + ":id/drag_layer|NexusOverviewActionsView:id/overview_actions_view"
+                    + "|LinearLayout:id/action_buttons"
+    );
+    // Minimal increase or decrease of view's alpha between frames that triggers the error.
+    private static final float ALPHA_JUMP_THRESHOLD = 1f;
+
+    @Override
+    void initializeNode(AnalysisNode info) {
+        // If the parent view ignores alpha jumps, its descendants will too.
+        final boolean parentIgnoreAlphaJumps = info.parent != null && info.parent.ignoreAlphaJumps;
+        info.ignoreAlphaJumps = parentIgnoreAlphaJumps
+                || PATHS_TO_IGNORE.contains(diagPathFromRoot(info));
+    }
+
+    @Override
+    void detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
+        // If the view was previously seen, proceed with analysis only if it was present in the
+        // view hierarchy in the previous frame.
+        if (oldInfo != null && oldInfo.frameN != frameN) return;
+
+        final AnalysisNode latestInfo = newInfo != null ? newInfo : oldInfo;
+        if (latestInfo.ignoreAlphaJumps) return;
+
+        final float oldAlpha = oldInfo != null ? oldInfo.alpha : 0;
+        final float newAlpha = newInfo != null ? newInfo.alpha : 0;
+        final float alphaDeltaAbs = Math.abs(newAlpha - oldAlpha);
+
+        if (alphaDeltaAbs >= ALPHA_JUMP_THRESHOLD) {
+            throw new AssertionError(
+                    String.format(
+                            "Alpha jump detected in ViewCapture data: alpha change: %s (%s -> %s)"
+                                    + ", threshold: %s, view: %s",
+                            alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD, latestInfo));
+        }
+    }
+}
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
new file mode 100644
index 0000000..5a2611c
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -0,0 +1,238 @@
+/*
+ * 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.launcher3.util.viewcapture_analysis;
+
+import static android.view.View.VISIBLE;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.app.viewcapture.data.ExportedData;
+import com.android.app.viewcapture.data.FrameData;
+import com.android.app.viewcapture.data.ViewNode;
+import com.android.app.viewcapture.data.WindowData;
+
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Utility that analyzes ViewCapture data and finds anomalies such as views appearing or
+ * disappearing without alpha-fading.
+ */
+public class ViewCaptureAnalyzer {
+    private static final String SCRIM_VIEW_CLASS = "com.android.launcher3.views.ScrimView";
+
+    /**
+     * Detector of one kind of anomaly.
+     */
+    abstract static class AnomalyDetector {
+        /**
+         * Initializes fields of the node that are specific to the anomaly detected by this
+         * detector.
+         */
+        abstract void initializeNode(@NonNull AnalysisNode info);
+
+        /**
+         * Detects anomalies by looking at the last occurrence of a view, and the current one.
+         * null value means that the view. 'oldInfo' and 'newInfo' cannot be both null.
+         * If an anomaly is detected, an exception will be thrown.
+         *
+         * @param oldInfo the view, as seen in the last frame that contained it in the view
+         *                hierarchy before 'currentFrame'. 'null' means that the view is first seen
+         *                in the 'currentFrame'.
+         * @param newInfo the view in the view hierarchy of the 'currentFrame'. 'null' means that
+         *                the view is not present in the 'currentFrame', but was present in earlier
+         *                frames.
+         * @param frameN  number of the current frame.
+         */
+        abstract void detectAnomalies(
+                @Nullable AnalysisNode oldInfo, @Nullable AnalysisNode newInfo, int frameN);
+    }
+
+    // All detectors. They will be invoked in the order listed here.
+    private static final Iterable<AnomalyDetector> ANOMALY_DETECTORS = Arrays.asList(
+            new AlphaJumpDetector()
+    );
+
+    // A view from view capture data converted to a form that's convenient for detecting anomalies.
+    static class AnalysisNode {
+        public String className;
+        public String resourceId;
+        public AnalysisNode parent;
+
+        // Window coordinates of the view.
+        public float left;
+        public float top;
+
+        // Visible scale and alpha, build recursively from the ancestor list.
+        public float scaleX;
+        public float scaleY;
+        public float alpha;
+
+        public int frameN;
+        public ViewNode viewCaptureNode;
+
+        public boolean ignoreAlphaJumps;
+
+        @Override
+        public String toString() {
+            return String.format("window coordinates: (%s, %s), class path from the root: %s",
+                    left, top, diagPathFromRoot(this));
+        }
+    }
+
+    /**
+     * Scans a view capture record and throws an error if an anomaly is found.
+     */
+    public static void assertNoAnomalies(ExportedData viewCaptureData) {
+        final int scrimClassIndex = viewCaptureData.getClassnameList().indexOf(SCRIM_VIEW_CLASS);
+
+        final int windowDataCount = viewCaptureData.getWindowDataCount();
+        for (int i = 0; i < windowDataCount; ++i) {
+            analyzeWindowData(viewCaptureData, viewCaptureData.getWindowData(i), scrimClassIndex);
+        }
+    }
+
+    private static void analyzeWindowData(ExportedData viewCaptureData, WindowData windowData,
+            int scrimClassIndex) {
+        // View hash code => Last seen node with this hash code.
+        // The view is added when we analyze the first frame where it's visible.
+        // After that, it gets updated for every frame where it's visible.
+        // As we go though frames, if a view becomes invisible, it stays in the map.
+        final Map<Integer, AnalysisNode> lastSeenNodes = new HashMap<>();
+
+        for (int frameN = 0; frameN < windowData.getFrameDataCount(); ++frameN) {
+            analyzeFrame(frameN, windowData.getFrameData(frameN), viewCaptureData, lastSeenNodes,
+                    scrimClassIndex);
+        }
+    }
+
+    private static void analyzeFrame(int frameN, FrameData frame, ExportedData viewCaptureData,
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+        // Analyze the node tree starting from the root.
+        analyzeView(
+                frame.getNode(),
+                /* parent = */ null,
+                frameN,
+                /* leftShift = */ 0,
+                /* topShift = */ 0,
+                viewCaptureData,
+                lastSeenNodes,
+                scrimClassIndex);
+
+        // Analyze transitions when a view visible in the last frame become invisible in the
+        // current one.
+        for (AnalysisNode info : lastSeenNodes.values()) {
+            if (info.frameN == frameN - 1) {
+                if (!info.viewCaptureNode.getWillNotDraw()) {
+                    ANOMALY_DETECTORS.forEach(
+                            detector -> detector.detectAnomalies(
+                                    /* oldInfo = */ info,
+                                    /* newInfo = */ null,
+                                    frameN));
+                }
+            }
+        }
+    }
+
+    private static void analyzeView(ViewNode viewCaptureNode, AnalysisNode parent, int frameN,
+            float leftShift, float topShift, ExportedData viewCaptureData,
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+        // Skip analysis of invisible views
+        final float parentAlpha = parent != null ? parent.alpha : 1;
+        final float alpha = getVisibleAlpha(viewCaptureNode, parentAlpha);
+        if (alpha <= 0.0) return;
+
+        // Calculate analysis node parameters
+        final int hashcode = viewCaptureNode.getHashcode();
+        final int classIndex = viewCaptureNode.getClassnameIndex();
+
+        final float parentScaleX = parent != null ? parent.scaleX : 1;
+        final float parentScaleY = parent != null ? parent.scaleY : 1;
+        final float scaleX = parentScaleX * viewCaptureNode.getScaleX();
+        final float scaleY = parentScaleY * viewCaptureNode.getScaleY();
+
+        final float left = leftShift
+                + (viewCaptureNode.getLeft() + viewCaptureNode.getTranslationX()) * parentScaleX
+                + viewCaptureNode.getWidth() * (parentScaleX - scaleX) / 2;
+        final float top = topShift
+                + (viewCaptureNode.getTop() + viewCaptureNode.getTranslationY()) * parentScaleY
+                + viewCaptureNode.getHeight() * (parentScaleY - scaleY) / 2;
+
+        // Initialize new analysis node
+        final AnalysisNode newAnalysisNode = new AnalysisNode();
+        newAnalysisNode.className = viewCaptureData.getClassname(classIndex);
+        newAnalysisNode.resourceId = viewCaptureNode.getId();
+        newAnalysisNode.parent = parent;
+        newAnalysisNode.left = left;
+        newAnalysisNode.top = top;
+        newAnalysisNode.scaleX = scaleX;
+        newAnalysisNode.scaleY = scaleY;
+        newAnalysisNode.alpha = alpha;
+        newAnalysisNode.frameN = frameN;
+        newAnalysisNode.viewCaptureNode = viewCaptureNode;
+        ANOMALY_DETECTORS.forEach(detector -> detector.initializeNode(newAnalysisNode));
+
+        // Detect anomalies for the view
+        final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null
+        if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) {
+            ANOMALY_DETECTORS.forEach(
+                    detector -> detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN));
+        }
+        lastSeenNodes.put(hashcode, newAnalysisNode);
+
+        // Enumerate children starting from the topmost one. Stop at ScrimView, if present.
+        final float leftShiftForChildren = left - viewCaptureNode.getScrollX();
+        final float topShiftForChildren = top - viewCaptureNode.getScrollY();
+        for (int i = viewCaptureNode.getChildrenCount() - 1; i >= 0; --i) {
+            final ViewNode child = viewCaptureNode.getChildren(i);
+
+            // Don't analyze anything under scrim view because we don't know whether it's
+            // transparent.
+            if (child.getClassnameIndex() == scrimClassIndex) break;
+
+            analyzeView(child, newAnalysisNode, frameN, leftShiftForChildren, topShiftForChildren,
+                    viewCaptureData, lastSeenNodes,
+                    scrimClassIndex);
+        }
+    }
+
+    private static float getVisibleAlpha(ViewNode node, float parenVisibleAlpha) {
+        return node.getVisibility() == VISIBLE
+                ? parenVisibleAlpha * Math.max(0, Math.min(node.getAlpha(), 1))
+                : 0f;
+    }
+
+    private static String classNameToSimpleName(String className) {
+        return className.substring(className.lastIndexOf(".") + 1);
+    }
+
+    static String diagPathFromRoot(AnalysisNode nodeBox) {
+        final StringBuilder path = new StringBuilder(diagPathElement(nodeBox));
+        for (AnalysisNode ancestor = nodeBox.parent; ancestor != null; ancestor = ancestor.parent) {
+            path.insert(0, diagPathElement(ancestor) + "|");
+        }
+        return path.toString();
+    }
+
+    private static String diagPathElement(AnalysisNode nodeBox) {
+        final StringBuilder sb = new StringBuilder();
+        sb.append(classNameToSimpleName(nodeBox.className));
+        if (!"NO_ID".equals(nodeBox.resourceId)) sb.append(":" + nodeBox.resourceId);
+        return sb.toString();
+    }
+}