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 0000000000..e40fb79b1b --- /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 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 0000000000..5a2611c362 --- /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 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 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 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 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(); + } +}