Merge "Detecting multiple view animation anomalies." into udc-qpr-dev
diff --git a/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt b/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
index b4ad1f3..f3f9b89 100644
--- a/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
+++ b/tests/src/com/android/launcher3/util/rule/ViewCaptureRule.kt
@@ -26,8 +26,13 @@
 import com.android.launcher3.tapl.TestHelpers
 import com.android.launcher3.util.ActivityLifecycleCallbacksAdapter
 import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer
-import org.junit.Assert.assertTrue
+import java.io.BufferedOutputStream
+import java.io.FileOutputStream
+import java.io.IOException
+import java.io.OutputStreamWriter
 import java.util.function.Supplier
+import org.junit.Assert.assertTrue
+import org.junit.Assert.fail
 import org.junit.rules.TestRule
 import org.junit.runner.Description
 import org.junit.runners.model.Statement
@@ -81,7 +86,7 @@
                     MAIN_EXECUTOR.execute { windowListenerCloseables.onEach(SafeCloseable::close) }
                 }
 
-                analyzeViewCapture()
+                analyzeViewCapture(description)
             }
 
             private fun startCapturingExistingActivity(
@@ -107,16 +112,39 @@
         }
     }
 
-    private fun analyzeViewCapture() {
+    private fun analyzeViewCapture(description: Description) {
         // OOP tests don't produce ViewCapture data
         if (!TestHelpers.isInLauncherProcess()) return
 
-        ViewCaptureAnalyzer.assertNoAnomalies(viewCaptureData)
-
         var frameCount = 0
         for (i in 0 until viewCaptureData!!.windowDataCount) {
             frameCount += viewCaptureData!!.getWindowData(i).frameDataCount
         }
         assertTrue("Empty ViewCapture data", frameCount > 0)
+
+        val anomalies: Map<String, String> = ViewCaptureAnalyzer.getAnomalies(viewCaptureData)
+        if (!anomalies.isEmpty()) {
+            val diagFile = FailureWatcher.diagFile(description, "ViewAnomalies", "txt")
+            try {
+                OutputStreamWriter(BufferedOutputStream(FileOutputStream(diagFile))).use { writer ->
+                    writer.write("View animation anomalies detected.\r\n")
+                    writer.write(
+                        "To suppress an anomaly for a view, add its full path to the PATHS_TO_IGNORE list in the corresponding AnomalyDetector.\r\n"
+                    )
+                    writer.write("List of views with animation anomalies:\r\n")
+
+                    for ((viewPath, message) in anomalies) {
+                        writer.write("View: $viewPath\r\n        $message\r\n")
+                    }
+                }
+            } catch (ex: IOException) {
+                throw RuntimeException(ex)
+            }
+
+            val (viewPath, message) = anomalies.entries.first()
+            fail(
+                "${anomalies.size} view(s) had animation anomalies during the test, including view: $viewPath: $message\r\nSee ${diagFile.name} for details."
+            )
+        }
     }
 }
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
index cb404b9..f67f341 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
@@ -212,24 +212,26 @@
     }
 
     @Override
-    void detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
+    String 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;
+        if (oldInfo != null && oldInfo.frameN != frameN) return null;
 
         final AnalysisNode latestInfo = newInfo != null ? newInfo : oldInfo;
-        if (getNodeData(latestInfo).ignoreAlphaJumps) return;
+        final NodeData nodeData = getNodeData(latestInfo);
+        if (nodeData.ignoreAlphaJumps) return null;
 
         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));
+            nodeData.ignoreAlphaJumps = true; // No need to report alpha jump in children.
+            return String.format(
+                    "Alpha jump detected in ViewCapture data: alpha change: %s (%s -> %s)"
+                            + ", threshold: %s, %s", // ----------- no need to include view?
+                    alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD, latestInfo);
         }
+        return null;
     }
 }
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
index 2cf3843..949c536 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -61,8 +61,9 @@
          *                the view is not present in the 'currentFrame', but was present in earlier
          *                frames.
          * @param frameN  number of the current frame.
+         * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise.
          */
-        abstract void detectAnomalies(
+        abstract String detectAnomalies(
                 @Nullable AnalysisNode oldInfo, @Nullable AnalysisNode newInfo, int frameN);
     }
 
@@ -101,25 +102,31 @@
 
         @Override
         public String toString() {
-            return String.format("window coordinates: (%s, %s), class path from the root: %s",
-                    left, top, diagPathFromRoot(this));
+            return String.format("view window coordinates: (%s, %s)", left, top);
         }
     }
 
     /**
-     * Scans a view capture record and throws an error if an anomaly is found.
+     * Scans a view capture record and searches for view animation anomalies. Can find anomalies for
+     * multiple views.
+     * Returns a map from the view path to the anomaly message for the view. Non-empty map means
+     * that anomalies were detected.
      */
-    public static void assertNoAnomalies(ExportedData viewCaptureData) {
+    public static Map<String, String> getAnomalies(ExportedData viewCaptureData) {
+        final Map<String, String> anomalies = new HashMap<>();
+
         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);
+            analyzeWindowData(
+                    viewCaptureData, viewCaptureData.getWindowData(i), scrimClassIndex, anomalies);
         }
+        return anomalies;
     }
 
     private static void analyzeWindowData(ExportedData viewCaptureData, WindowData windowData,
-            int scrimClassIndex) {
+            int scrimClassIndex, Map<String, String> anomalies) {
         // 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.
@@ -128,12 +135,13 @@
 
         for (int frameN = 0; frameN < windowData.getFrameDataCount(); ++frameN) {
             analyzeFrame(frameN, windowData.getFrameData(frameN), viewCaptureData, lastSeenNodes,
-                    scrimClassIndex);
+                    scrimClassIndex, anomalies);
         }
     }
 
     private static void analyzeFrame(int frameN, FrameData frame, ExportedData viewCaptureData,
-            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
+            Map<String, String> anomalies) {
         // Analyze the node tree starting from the root.
         analyzeView(
                 frame.getNode(),
@@ -143,7 +151,8 @@
                 /* topShift = */ 0,
                 viewCaptureData,
                 lastSeenNodes,
-                scrimClassIndex);
+                scrimClassIndex,
+                anomalies);
 
         // Analyze transitions when a view visible in the last frame become invisible in the
         // current one.
@@ -151,10 +160,14 @@
             if (info.frameN == frameN - 1) {
                 if (!info.viewCaptureNode.getWillNotDraw()) {
                     Arrays.stream(ANOMALY_DETECTORS).forEach(
-                            detector -> detector.detectAnomalies(
-                                    /* oldInfo = */ info,
-                                    /* newInfo = */ null,
-                                    frameN));
+                            detector ->
+                                    detectAnomaly(
+                                            detector,
+                                            frameN,
+                                            /* oldInfo = */ info,
+                                            /* newInfo = */ null,
+                                            anomalies)
+                    );
                 }
             }
         }
@@ -162,7 +175,8 @@
 
     private static void analyzeView(ViewNode viewCaptureNode, AnalysisNode parent, int frameN,
             float leftShift, float topShift, ExportedData viewCaptureData,
-            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex) {
+            Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
+            Map<String, String> anomalies) {
         // Skip analysis of invisible views
         final float parentAlpha = parent != null ? parent.alpha : 1;
         final float alpha = getVisibleAlpha(viewCaptureNode, parentAlpha);
@@ -205,7 +219,10 @@
         final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null
         if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) {
             Arrays.stream(ANOMALY_DETECTORS).forEach(
-                    detector -> detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN));
+                    detector ->
+                            detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode,
+                                    anomalies)
+            );
         }
         lastSeenNodes.put(hashcode, newAnalysisNode);
 
@@ -220,8 +237,20 @@
             if (child.getClassnameIndex() == scrimClassIndex) break;
 
             analyzeView(child, newAnalysisNode, frameN, leftShiftForChildren, topShiftForChildren,
-                    viewCaptureData, lastSeenNodes,
-                    scrimClassIndex);
+                    viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies);
+        }
+    }
+
+    private static void detectAnomaly(AnomalyDetector detector, int frameN,
+            AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode,
+            Map<String, String> anomalies) {
+        final String maybeAnomaly =
+                detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN);
+        if (maybeAnomaly != null) {
+            final String viewDiagPath = diagPathFromRoot(newAnalysisNode);
+            if (!anomalies.containsKey(viewDiagPath)) {
+                anomalies.put(viewDiagPath, maybeAnomaly);
+            }
         }
     }
 
@@ -235,9 +264,11 @@
         return className.substring(className.lastIndexOf(".") + 1);
     }
 
-    private static String diagPathFromRoot(AnalysisNode nodeBox) {
-        final StringBuilder path = new StringBuilder(nodeBox.nodeIdentity);
-        for (AnalysisNode ancestor = nodeBox.parent; ancestor != null; ancestor = ancestor.parent) {
+    private static String diagPathFromRoot(AnalysisNode analysisNode) {
+        final StringBuilder path = new StringBuilder(analysisNode.nodeIdentity);
+        for (AnalysisNode ancestor = analysisNode.parent;
+                ancestor != null;
+                ancestor = ancestor.parent) {
             path.insert(0, ancestor.nodeIdentity + "|");
         }
         return path.toString();