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 49abad4bee..4b65439db5 100644 --- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java +++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java @@ -180,7 +180,8 @@ final class AlphaJumpDetector extends AnomalyDetector { } @Override - String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, long timestamp) { + String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, long timestamp, + int windowSizePx) { // 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 null; diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java index 09e2f6509b..786791c22f 100644 --- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java +++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java @@ -68,17 +68,18 @@ abstract class AnomalyDetector { * 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 the previous - * frame. - * @param frameN number of the current frame. + * @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 the + * previous frame. + * @param frameN number of the current frame. + * @param windowSizePx maximum of the window width and height, in pixels. * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise. */ abstract String detectAnomalies( @Nullable ViewCaptureAnalyzer.AnalysisNode oldInfo, @Nullable ViewCaptureAnalyzer.AnalysisNode newInfo, int frameN, - long frameTimeNs); + long frameTimeNs, int windowSizePx); } diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java index 6d9198f33e..8b88ace181 100644 --- a/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java +++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java @@ -110,7 +110,7 @@ final class FlashDetector extends AnomalyDetector { @Override String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, - long frameTimeNs) { + long frameTimeNs, int windowSizePx) { // Should we check when a view was visible for a short period, then its alpha became 0? // Then 'lastVisible' time should be the last one still visible? // Check only transitions of alpha between 0 and 1? diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/PositionJumpDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/PositionJumpDetector.java new file mode 100644 index 0000000000..a1ddcb054e --- /dev/null +++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/PositionJumpDetector.java @@ -0,0 +1,126 @@ +/* + * 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 com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode; + +import java.util.List; + +/** + * Anomaly detector that triggers an error when a view position jumps. + */ +final class PositionJumpDetector extends AnomalyDetector { + // Maximum allowed jump in "milliwindows", i.e. a 1/1000's of the maximum of the window + // dimensions. + private static final float JUMP_MIW = 250; + + private static final String[] BORDER_NAMES = {"left", "top", "right", "bottom"}; + + // Commonly used parts of the paths to ignore. + private static final String CONTENT = "DecorView|LinearLayout|FrameLayout:id/content|"; + private static final String DRAG_LAYER = + CONTENT + "LauncherRootView:id/launcher|DragLayer:id/drag_layer|"; + private static final String RECENTS_DRAG_LAYER = + CONTENT + "LauncherRootView:id/launcher|RecentsDragLayer:id/drag_layer|"; + + private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree(List.of( + DRAG_LAYER + "SearchContainerView:id/apps_view", + DRAG_LAYER + "AppWidgetResizeFrame", + DRAG_LAYER + "LauncherAllAppsContainerView:id/apps_view", + CONTENT + + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id" + + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content", + DRAG_LAYER + "WidgetsTwoPaneSheet|SpringRelativeLayout:id/container", + DRAG_LAYER + "WidgetsFullSheet|SpringRelativeLayout:id/container", + DRAG_LAYER + "LauncherDragView", + RECENTS_DRAG_LAYER + "FallbackRecentsView:id/overview_panel|TaskView", + CONTENT + "LauncherRootView:id/launcher|FloatingIconView", + DRAG_LAYER + "FloatingTaskView", + DRAG_LAYER + "LauncherRecentsView:id/overview_panel" + )); + + // Per-AnalysisNode data that's specific to this detector. + private static class NodeData { + public boolean ignoreJumps; + + // If ignoreNode is null, then this AnalysisNode node will be ignored if its parent is + // ignored. + // Otherwise, this AnalysisNode will be ignored if ignoreNode is a leaf i.e. has no + // children. + public IgnoreNode ignoreNode; + } + + private NodeData getNodeData(AnalysisNode info) { + return (NodeData) info.detectorsData[detectorOrdinal]; + } + + @Override + void initializeNode(AnalysisNode info) { + final NodeData nodeData = new NodeData(); + info.detectorsData[detectorOrdinal] = nodeData; + + // If the parent view ignores jumps, its descendants will too. + final boolean parentIgnoresJumps = info.parent != null && getNodeData( + info.parent).ignoreJumps; + if (parentIgnoresJumps) { + nodeData.ignoreJumps = true; + return; + } + + // Parent view doesn't ignore jumps. + // Initialize this AnalysisNode's ignore-node with the corresponding child of the + // ignore-node of the parent, if present. + final IgnoreNode parentIgnoreNode = info.parent != null + ? getNodeData(info.parent).ignoreNode + : IGNORED_NODES_ROOT; + nodeData.ignoreNode = parentIgnoreNode != null + ? parentIgnoreNode.children.get(info.nodeIdentity) : null; + // AnalysisNode will be ignored if the corresponding ignore-node is a leaf. + nodeData.ignoreJumps = + nodeData.ignoreNode != null && nodeData.ignoreNode.children.isEmpty(); + } + + @Override + String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, + long frameTimeNs, int windowSizePx) { + // If the view is not present in the current frame, there can't be a jump detected in the + // current frame. + if (newInfo == null) return null; + + // We only detect position jumps if the view was visible in the previous frame. + if (oldInfo == null || frameN != oldInfo.frameN + 1) return null; + + final NodeData newNodeData = getNodeData(newInfo); + if (newNodeData.ignoreJumps) return null; + + final float[] positionDiffs = { + newInfo.left - oldInfo.left, + newInfo.top - oldInfo.top, + newInfo.right - oldInfo.right, + newInfo.bottom - oldInfo.bottom + }; + + for (int i = 0; i < 4; ++i) { + final float positionDiffAbs = Math.abs(positionDiffs[i]); + if (positionDiffAbs * 1000 > JUMP_MIW * windowSizePx) { + newNodeData.ignoreJumps = true; + return String.format("Position jump: %s jumped by %s", + BORDER_NAMES[i], positionDiffAbs); + } + } + 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 ccb4a1e228..9459cc2d13 100644 --- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java +++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java @@ -36,7 +36,8 @@ public class ViewCaptureAnalyzer { // All detectors. They will be invoked in the order listed here. private static final AnomalyDetector[] ANOMALY_DETECTORS = { new AlphaJumpDetector(), - new FlashDetector() + new FlashDetector(), + new PositionJumpDetector() }; static { @@ -52,6 +53,8 @@ public class ViewCaptureAnalyzer { // Window coordinates of the view. public float left; public float top; + public float right; + public float bottom; // Visible scale and alpha, build recursively from the ancestor list. public float scaleX; @@ -81,7 +84,8 @@ public class ViewCaptureAnalyzer { @Override public String toString() { - return String.format("view window coordinates: (%s, %s)", left, top); + return String.format("view window coordinates: (%s, %s, %s, %s)", + left, top, right, bottom); } } @@ -112,15 +116,33 @@ public class ViewCaptureAnalyzer { // As we go though frames, if a view becomes invisible, it stays in the map. final Map lastSeenNodes = new HashMap<>(); + int windowWidthPx = -1; + int windowHeightPx = -1; + for (int frameN = 0; frameN < windowData.getFrameDataCount(); ++frameN) { - analyzeFrame(frameN, windowData.getFrameData(frameN), viewCaptureData, lastSeenNodes, - scrimClassIndex, anomalies); + final FrameData frame = windowData.getFrameData(frameN); + final ViewNode rootNode = frame.getNode(); + + // If the rotation or window size has changed, reset the analyzer state. + final boolean isFirstFrame = windowWidthPx != rootNode.getWidth() + || windowHeightPx != rootNode.getHeight(); + if (isFirstFrame) { + windowWidthPx = rootNode.getWidth(); + windowHeightPx = rootNode.getHeight(); + lastSeenNodes.clear(); + } + + final int windowSizePx = Math.max(rootNode.getWidth(), rootNode.getHeight()); + + analyzeFrame(frameN, isFirstFrame, frame, viewCaptureData, lastSeenNodes, + scrimClassIndex, anomalies, windowSizePx); } } - private static void analyzeFrame(int frameN, FrameData frame, ExportedData viewCaptureData, + private static void analyzeFrame(int frameN, boolean isFirstFrame, FrameData frame, + ExportedData viewCaptureData, Map lastSeenNodes, int scrimClassIndex, - Map anomalies) { + Map anomalies, int windowSizePx) { // Analyze the node tree starting from the root. long frameTimeNs = frame.getTimestamp(); analyzeView( @@ -128,12 +150,14 @@ public class ViewCaptureAnalyzer { frame.getNode(), /* parent = */ null, frameN, + isFirstFrame, /* leftShift = */ 0, /* topShift = */ 0, viewCaptureData, lastSeenNodes, scrimClassIndex, - anomalies); + anomalies, + windowSizePx); // Analyze transitions when a view visible in the previous frame became invisible in the // current one. @@ -148,7 +172,8 @@ public class ViewCaptureAnalyzer { /* oldInfo = */ info, /* newInfo = */ null, anomalies, - frameTimeNs) + frameTimeNs, + windowSizePx) ); } info.timeBecameInvisibleNs = info.alpha == 1 ? frameTimeNs : -1; @@ -159,9 +184,9 @@ public class ViewCaptureAnalyzer { private static void analyzeView(long frameTimeNs, ViewNode viewCaptureNode, AnalysisNode parent, int frameN, - float leftShift, float topShift, ExportedData viewCaptureData, + boolean isFirstFrame, float leftShift, float topShift, ExportedData viewCaptureData, Map lastSeenNodes, int scrimClassIndex, - Map anomalies) { + Map anomalies, int windowSizePx) { // Skip analysis of invisible views final float parentAlpha = parent != null ? parent.alpha : 1; final float alpha = getVisibleAlpha(viewCaptureNode, parentAlpha); @@ -182,6 +207,8 @@ public class ViewCaptureAnalyzer { final float top = topShift + (viewCaptureNode.getTop() + viewCaptureNode.getTranslationY()) * parentScaleY + viewCaptureNode.getHeight() * (parentScaleY - scaleY) / 2; + final float width = viewCaptureNode.getWidth() * scaleX; + final float height = viewCaptureNode.getHeight() * scaleY; // Initialize new analysis node final AnalysisNode newAnalysisNode = new AnalysisNode(); @@ -192,6 +219,8 @@ public class ViewCaptureAnalyzer { newAnalysisNode.parent = parent; newAnalysisNode.left = left; newAnalysisNode.top = top; + newAnalysisNode.right = left + width; + newAnalysisNode.bottom = top + height; newAnalysisNode.scaleX = scaleX; newAnalysisNode.scaleY = scaleY; newAnalysisNode.alpha = alpha; @@ -216,11 +245,11 @@ public class ViewCaptureAnalyzer { } // Detect anomalies for the view. - if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) { + if (!isFirstFrame && !viewCaptureNode.getWillNotDraw()) { Arrays.stream(ANOMALY_DETECTORS).forEach( detector -> detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode, - anomalies, frameTimeNs) + anomalies, frameTimeNs, windowSizePx) ); } lastSeenNodes.put(hashcode, newAnalysisNode); @@ -235,17 +264,19 @@ public class ViewCaptureAnalyzer { // transparent. if (child.getClassnameIndex() == scrimClassIndex) break; - analyzeView(frameTimeNs, child, newAnalysisNode, frameN, leftShiftForChildren, + analyzeView(frameTimeNs, child, newAnalysisNode, frameN, isFirstFrame, + leftShiftForChildren, topShiftForChildren, - viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies); + viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies, windowSizePx); } } private static void detectAnomaly(AnomalyDetector detector, int frameN, AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode, - Map anomalies, long frameTimeNs) { + Map anomalies, long frameTimeNs, int windowSizePx) { final String maybeAnomaly = - detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN, frameTimeNs); + detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN, frameTimeNs, + windowSizePx); if (maybeAnomaly != null) { AnalysisNode latestInfo = newAnalysisNode != null ? newAnalysisNode : oldAnalysisNode; final String viewDiagPath = diagPathFromRoot(latestInfo);