1 /* 2 * Copyright (C) 2023 The Android Open Source Project 3 * 4 * Licensed under the Apache License, Version 2.0 (the "License"); 5 * you may not use this file except in compliance with the License. 6 * You may obtain a copy of the License at 7 * 8 * http://www.apache.org/licenses/LICENSE-2.0 9 * 10 * Unless required by applicable law or agreed to in writing, software 11 * distributed under the License is distributed on an "AS IS" BASIS, 12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 * See the License for the specific language governing permissions and 14 * limitations under the License. 15 */ 16 package com.android.launcher3.util.viewcapture_analysis; 17 18 import static android.view.View.VISIBLE; 19 20 import com.android.app.viewcapture.data.ExportedData; 21 import com.android.app.viewcapture.data.FrameData; 22 import com.android.app.viewcapture.data.ViewNode; 23 import com.android.app.viewcapture.data.WindowData; 24 25 import java.util.Arrays; 26 import java.util.HashMap; 27 import java.util.Map; 28 29 /** 30 * Utility that analyzes ViewCapture data and finds anomalies such as views appearing or 31 * disappearing without alpha-fading. 32 */ 33 public class ViewCaptureAnalyzer { 34 private static final String SCRIM_VIEW_CLASS = "com.android.launcher3.views.ScrimView"; 35 36 // All detectors. They will be invoked in the order listed here. 37 private static final AnomalyDetector[] ANOMALY_DETECTORS = { 38 // new AlphaJumpDetector(), // b/309014345 39 // new FlashDetector(), // b/309014345 40 new PositionJumpDetector() 41 }; 42 43 static { 44 for (int i = 0; i < ANOMALY_DETECTORS.length; ++i) ANOMALY_DETECTORS[i].detectorOrdinal = i; 45 } 46 47 // A view from view capture data converted to a form that's convenient for detecting anomalies. 48 static class AnalysisNode { 49 public String className; 50 public String resourceId; 51 public AnalysisNode parent; 52 53 // Window coordinates of the view. 54 public float left; 55 public float top; 56 public float right; 57 public float bottom; 58 59 // Visible scale and alpha, build recursively from the ancestor list. 60 public float scaleX; 61 public float scaleY; 62 public float alpha; // Always > 0 63 64 public int frameN; 65 66 // Timestamp of the frame when this view became abruptly visible, i.e. its alpha became 1 67 // the next frame after it was 0 or the view wasn't visible. 68 // If the view is currently invisible or the last appearance wasn't abrupt, the value is -1. 69 public long timeBecameVisibleNs; 70 71 // Timestamp of the frame when this view became abruptly invisible last time, i.e. its 72 // alpha became 0, or view disappeared, after being 1 in the previous frame. 73 // If the view is currently visible or the last disappearance wasn't abrupt, the value is 74 // -1. 75 public long timeBecameInvisibleNs; 76 77 public ViewNode viewCaptureNode; 78 79 // Class name + resource id 80 public String nodeIdentity; 81 82 // Collection of detector-specific data for this node. 83 public final Object[] detectorsData = new Object[ANOMALY_DETECTORS.length]; 84 85 @Override toString()86 public String toString() { 87 return String.format("view window coordinates: (%s, %s, %s, %s)", 88 left, top, right, bottom); 89 } 90 } 91 92 /** 93 * Scans a view capture record and searches for view animation anomalies. Can find anomalies for 94 * multiple views. 95 * Returns a map from the view path to the anomaly message for the view. Non-empty map means 96 * that anomalies were detected. 97 */ getAnomalies(ExportedData viewCaptureData)98 public static Map<String, String> getAnomalies(ExportedData viewCaptureData) { 99 final Map<String, String> anomalies = new HashMap<>(); 100 101 final int scrimClassIndex = viewCaptureData.getClassnameList().indexOf(SCRIM_VIEW_CLASS); 102 103 final int windowDataCount = viewCaptureData.getWindowDataCount(); 104 for (int i = 0; i < windowDataCount; ++i) { 105 analyzeWindowData( 106 viewCaptureData, viewCaptureData.getWindowData(i), scrimClassIndex, anomalies); 107 } 108 return anomalies; 109 } 110 analyzeWindowData(ExportedData viewCaptureData, WindowData windowData, int scrimClassIndex, Map<String, String> anomalies)111 private static void analyzeWindowData(ExportedData viewCaptureData, WindowData windowData, 112 int scrimClassIndex, Map<String, String> anomalies) { 113 // View hash code => Last seen node with this hash code. 114 // The view is added when we analyze the first frame where it's visible. 115 // After that, it gets updated for every frame where it's visible. 116 // As we go though frames, if a view becomes invisible, it stays in the map. 117 final Map<Integer, AnalysisNode> lastSeenNodes = new HashMap<>(); 118 119 int windowWidthPx = -1; 120 int windowHeightPx = -1; 121 122 for (int frameN = 0; frameN < windowData.getFrameDataCount(); ++frameN) { 123 final FrameData frame = windowData.getFrameData(frameN); 124 final ViewNode rootNode = frame.getNode(); 125 126 // If the rotation or window size has changed, reset the analyzer state. 127 final boolean isFirstFrame = windowWidthPx != rootNode.getWidth() 128 || windowHeightPx != rootNode.getHeight(); 129 if (isFirstFrame) { 130 windowWidthPx = rootNode.getWidth(); 131 windowHeightPx = rootNode.getHeight(); 132 lastSeenNodes.clear(); 133 } 134 135 final int windowSizePx = Math.max(rootNode.getWidth(), rootNode.getHeight()); 136 137 analyzeFrame(frameN, isFirstFrame, frame, viewCaptureData, lastSeenNodes, 138 scrimClassIndex, anomalies, windowSizePx); 139 } 140 } 141 analyzeFrame(int frameN, boolean isFirstFrame, FrameData frame, ExportedData viewCaptureData, Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex, Map<String, String> anomalies, int windowSizePx)142 private static void analyzeFrame(int frameN, boolean isFirstFrame, FrameData frame, 143 ExportedData viewCaptureData, 144 Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex, 145 Map<String, String> anomalies, int windowSizePx) { 146 // Analyze the node tree starting from the root. 147 long frameTimeNs = frame.getTimestamp(); 148 analyzeView( 149 frameTimeNs, 150 frame.getNode(), 151 /* parent = */ null, 152 frameN, 153 isFirstFrame, 154 /* leftShift = */ 0, 155 /* topShift = */ 0, 156 viewCaptureData, 157 lastSeenNodes, 158 scrimClassIndex, 159 anomalies, 160 windowSizePx); 161 162 // Analyze transitions when a view visible in the previous frame became invisible in the 163 // current one. 164 for (AnalysisNode info : lastSeenNodes.values()) { 165 if (info.frameN == frameN - 1) { 166 if (!info.viewCaptureNode.getWillNotDraw()) { 167 Arrays.stream(ANOMALY_DETECTORS).forEach( 168 detector -> 169 detectAnomaly( 170 detector, 171 frameN, 172 /* oldInfo = */ info, 173 /* newInfo = */ null, 174 anomalies, 175 frameTimeNs, 176 windowSizePx) 177 ); 178 } 179 info.timeBecameInvisibleNs = info.alpha == 1 ? frameTimeNs : -1; 180 info.timeBecameVisibleNs = -1; 181 } 182 } 183 } 184 analyzeView(long frameTimeNs, ViewNode viewCaptureNode, AnalysisNode parent, int frameN, boolean isFirstFrame, float leftShift, float topShift, ExportedData viewCaptureData, Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex, Map<String, String> anomalies, int windowSizePx)185 private static void analyzeView(long frameTimeNs, ViewNode viewCaptureNode, AnalysisNode parent, 186 int frameN, 187 boolean isFirstFrame, float leftShift, float topShift, ExportedData viewCaptureData, 188 Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex, 189 Map<String, String> anomalies, int windowSizePx) { 190 // Skip analysis of invisible views 191 final float parentAlpha = parent != null ? parent.alpha : 1; 192 final float alpha = getVisibleAlpha(viewCaptureNode, parentAlpha); 193 if (alpha <= 0.0) return; 194 195 // Calculate analysis node parameters 196 final int hashcode = viewCaptureNode.getHashcode(); 197 final int classIndex = viewCaptureNode.getClassnameIndex(); 198 199 final float parentScaleX = parent != null ? parent.scaleX : 1; 200 final float parentScaleY = parent != null ? parent.scaleY : 1; 201 final float scaleX = parentScaleX * viewCaptureNode.getScaleX(); 202 final float scaleY = parentScaleY * viewCaptureNode.getScaleY(); 203 204 final float left = leftShift 205 + (viewCaptureNode.getLeft() + viewCaptureNode.getTranslationX()) * parentScaleX 206 + viewCaptureNode.getWidth() * (parentScaleX - scaleX) / 2; 207 final float top = topShift 208 + (viewCaptureNode.getTop() + viewCaptureNode.getTranslationY()) * parentScaleY 209 + viewCaptureNode.getHeight() * (parentScaleY - scaleY) / 2; 210 final float width = viewCaptureNode.getWidth() * scaleX; 211 final float height = viewCaptureNode.getHeight() * scaleY; 212 213 // Initialize new analysis node 214 final AnalysisNode newAnalysisNode = new AnalysisNode(); 215 newAnalysisNode.className = viewCaptureData.getClassname(classIndex); 216 newAnalysisNode.resourceId = viewCaptureNode.getId(); 217 newAnalysisNode.nodeIdentity = 218 getNodeIdentity(newAnalysisNode.className, newAnalysisNode.resourceId); 219 newAnalysisNode.parent = parent; 220 newAnalysisNode.left = left; 221 newAnalysisNode.top = top; 222 newAnalysisNode.right = left + width; 223 newAnalysisNode.bottom = top + height; 224 newAnalysisNode.scaleX = scaleX; 225 newAnalysisNode.scaleY = scaleY; 226 newAnalysisNode.alpha = alpha; 227 newAnalysisNode.frameN = frameN; 228 newAnalysisNode.timeBecameInvisibleNs = -1; 229 newAnalysisNode.viewCaptureNode = viewCaptureNode; 230 Arrays.stream(ANOMALY_DETECTORS).forEach( 231 detector -> detector.initializeNode(newAnalysisNode)); 232 233 final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null 234 235 if (oldAnalysisNode != null && oldAnalysisNode.frameN + 1 == frameN) { 236 // If this view was present in the previous frame, keep the time when it became visible. 237 newAnalysisNode.timeBecameVisibleNs = oldAnalysisNode.timeBecameVisibleNs; 238 } else { 239 // If the view is becoming visible after being invisible, initialize the time when it 240 // became visible with a new value. 241 // If the view became visible abruptly, i.e. alpha jumped from 0 to 1 between the 242 // previous and the current frames, then initialize with the time of the current 243 // frame. Otherwise, use -1. 244 newAnalysisNode.timeBecameVisibleNs = newAnalysisNode.alpha >= 1 ? frameTimeNs : -1; 245 } 246 247 // Detect anomalies for the view. 248 if (!isFirstFrame && !viewCaptureNode.getWillNotDraw()) { 249 Arrays.stream(ANOMALY_DETECTORS).forEach( 250 detector -> 251 detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode, 252 anomalies, frameTimeNs, windowSizePx) 253 ); 254 } 255 lastSeenNodes.put(hashcode, newAnalysisNode); 256 257 // Enumerate children starting from the topmost one. Stop at ScrimView, if present. 258 final float leftShiftForChildren = left - viewCaptureNode.getScrollX(); 259 final float topShiftForChildren = top - viewCaptureNode.getScrollY(); 260 for (int i = viewCaptureNode.getChildrenCount() - 1; i >= 0; --i) { 261 final ViewNode child = viewCaptureNode.getChildren(i); 262 263 // Don't analyze anything under scrim view because we don't know whether it's 264 // transparent. 265 if (child.getClassnameIndex() == scrimClassIndex) break; 266 267 analyzeView(frameTimeNs, child, newAnalysisNode, frameN, isFirstFrame, 268 leftShiftForChildren, 269 topShiftForChildren, 270 viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies, windowSizePx); 271 } 272 } 273 detectAnomaly(AnomalyDetector detector, int frameN, AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode, Map<String, String> anomalies, long frameTimeNs, int windowSizePx)274 private static void detectAnomaly(AnomalyDetector detector, int frameN, 275 AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode, 276 Map<String, String> anomalies, long frameTimeNs, int windowSizePx) { 277 final String maybeAnomaly = 278 detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN, frameTimeNs, 279 windowSizePx); 280 if (maybeAnomaly != null) { 281 AnalysisNode latestInfo = newAnalysisNode != null ? newAnalysisNode : oldAnalysisNode; 282 final String viewDiagPath = diagPathFromRoot(latestInfo); 283 if (!anomalies.containsKey(viewDiagPath)) { 284 anomalies.put(viewDiagPath, String.format("%s, %s", maybeAnomaly, latestInfo)); 285 } 286 } 287 } 288 getVisibleAlpha(ViewNode node, float parenVisibleAlpha)289 private static float getVisibleAlpha(ViewNode node, float parenVisibleAlpha) { 290 return node.getVisibility() == VISIBLE 291 ? parenVisibleAlpha * Math.max(0, Math.min(node.getAlpha(), 1)) 292 : 0f; 293 } 294 classNameToSimpleName(String className)295 private static String classNameToSimpleName(String className) { 296 return className.substring(className.lastIndexOf(".") + 1); 297 } 298 diagPathFromRoot(AnalysisNode analysisNode)299 private static String diagPathFromRoot(AnalysisNode analysisNode) { 300 final StringBuilder path = new StringBuilder(analysisNode.nodeIdentity); 301 for (AnalysisNode ancestor = analysisNode.parent; 302 ancestor != null; 303 ancestor = ancestor.parent) { 304 path.insert(0, ancestor.nodeIdentity + "|"); 305 } 306 return path.toString(); 307 } 308 getNodeIdentity(String className, String resourceId)309 private static String getNodeIdentity(String className, String resourceId) { 310 final StringBuilder sb = new StringBuilder(); 311 sb.append(classNameToSimpleName(className)); 312 if (!"NO_ID".equals(resourceId)) sb.append(":" + resourceId); 313 return sb.toString(); 314 } 315 } 316