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