Implementing detector of view flashes
I.e. when a view appears or disappears for a short time.
Moving some common parts of AlphaJumpDetector and FlashDetector to their parent class, AnomalyDetector, and moving AnomalyDetector to a separate file.
Also tweaking the code a bit.
Flag: N/A
Test: presubmit, local runs
Bug: 286251603
Change-Id: I022e68eb90147abd3ed4ee3b285d672bb19c997d
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 a147350..193af49 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AlphaJumpDetector.java
@@ -16,11 +16,8 @@
package com.android.launcher3.util.viewcapture_analysis;
import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode;
-import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnomalyDetector;
-import java.util.HashMap;
import java.util.List;
-import java.util.Map;
/**
* Anomaly detector that triggers an error when alpha of a view changes too rapidly.
@@ -34,8 +31,7 @@
private static final String RECENTS_DRAG_LAYER =
CONTENT + "LauncherRootView:id/launcher|RecentsDragLayer:id/drag_layer|";
- // Paths of nodes that are excluded from analysis.
- private static final Iterable<String> PATHS_TO_IGNORE = List.of(
+ private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree(List.of(
CONTENT
+ "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
+ "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
@@ -135,38 +131,7 @@
+ "NexusOverviewActionsView:id/overview_actions_view"
+ "|LinearLayout:id/action_buttons|ImageButton:id/action_split",
DRAG_LAYER + "IconView"
- );
-
- /**
- * Element of the tree of ignored nodes.
- * If the "children" map is empty, then this node should be ignored, i.e. alpha jumps analysis
- * shouldn't run for it.
- * I.e. ignored nodes correspond to the leaves in the ignored nodes tree.
- */
- private static class IgnoreNode {
- // Map from child node identities to ignore-nodes for these children.
- public final Map<String, IgnoreNode> children = new HashMap<>();
- }
-
- private static final IgnoreNode IGNORED_NODES_ROOT = buildIgnoreNodesTree();
-
- // Converts the list of full paths of nodes to ignore to a more efficient tree of ignore-nodes.
- private static IgnoreNode buildIgnoreNodesTree() {
- final IgnoreNode root = new IgnoreNode();
- for (String pathToIgnore : PATHS_TO_IGNORE) {
- // Scan the diag path of an ignored node and add its elements into the tree.
- IgnoreNode currentIgnoreNode = root;
- for (String part : pathToIgnore.split("\\|")) {
- // Ensure that the child of the node is added to the tree.
- IgnoreNode child = currentIgnoreNode.children.get(part);
- if (child == null) {
- currentIgnoreNode.children.put(part, child = new IgnoreNode());
- }
- currentIgnoreNode = child;
- }
- }
- return root;
- }
+ ));
// Minimal increase or decrease of view's alpha between frames that triggers the error.
private static final float ALPHA_JUMP_THRESHOLD = 1f;
@@ -213,7 +178,7 @@
}
@Override
- String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN) {
+ String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN, long timestamp) {
// 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;
@@ -229,9 +194,8 @@
if (alphaDeltaAbs >= ALPHA_JUMP_THRESHOLD) {
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);
+ "Alpha jump detected: alpha change: %s (%s -> %s), threshold: %s",
+ alphaDeltaAbs, oldAlpha, newAlpha, ALPHA_JUMP_THRESHOLD);
}
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
new file mode 100644
index 0000000..09e2f65
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/AnomalyDetector.java
@@ -0,0 +1,84 @@
+/*
+ * 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 androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import java.util.HashMap;
+import java.util.Map;
+
+/**
+ * Detector of one kind of anomaly.
+ */
+abstract class AnomalyDetector {
+ // Index of this detector in ViewCaptureAnalyzer.ANOMALY_DETECTORS
+ public int detectorOrdinal;
+
+ /**
+ * Element of the tree of ignored nodes.
+ * If the "children" map is empty, then this node should be ignored, i.e. the analysis shouldn't
+ * run for it.
+ * I.e. ignored nodes correspond to the leaves in the ignored nodes tree.
+ */
+ protected static class IgnoreNode {
+ // Map from child node identities to ignore-nodes for these children.
+ public final Map<String, IgnoreNode> children = new HashMap<>();
+ }
+
+ // Converts the list of full paths of nodes to ignore to a more efficient tree of ignore-nodes.
+ protected static IgnoreNode buildIgnoreNodesTree(Iterable<String> pathsToIgnore) {
+ final IgnoreNode root = new IgnoreNode();
+ for (String pathToIgnore : pathsToIgnore) {
+ // Scan the diag path of an ignored node and add its elements into the tree.
+ IgnoreNode currentIgnoreNode = root;
+ for (String part : pathToIgnore.split("\\|")) {
+ // Ensure that the child of the node is added to the tree.
+ IgnoreNode child = currentIgnoreNode.children.get(part);
+ if (child == null) {
+ currentIgnoreNode.children.put(part, child = new IgnoreNode());
+ }
+ currentIgnoreNode = child;
+ }
+ }
+ return root;
+ }
+
+ /**
+ * Initializes fields of the node that are specific to the anomaly detected by this
+ * detector.
+ */
+ abstract void initializeNode(@NonNull ViewCaptureAnalyzer.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 the previous
+ * frame.
+ * @param frameN number of the current frame.
+ * @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);
+}
diff --git a/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java
new file mode 100644
index 0000000..d9517b0
--- /dev/null
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/FlashDetector.java
@@ -0,0 +1,173 @@
+/*
+ * 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 org.junit.Assert.assertTrue;
+
+import com.android.launcher3.util.viewcapture_analysis.ViewCaptureAnalyzer.AnalysisNode;
+
+import java.util.List;
+
+/**
+ * Anomaly detector that triggers an error when a view flashes, i.e. appears or disappears for a too
+ * short period of time.
+ */
+final class FlashDetector extends AnomalyDetector {
+ // Maximum time period of a view visibility or invisibility that is recognized as a flash.
+ private static final int FLASH_DURATION_MS = 300;
+
+ // 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(
+ CONTENT + "LauncherRootView:id/launcher|FloatingIconView",
+ DRAG_LAYER
+ + "SearchContainerView:id/apps_view|AllAppsRecyclerView:id/apps_list_view"
+ + "|BubbleTextView:id/icon",
+ DRAG_LAYER + "LauncherDragView|ImageView",
+ DRAG_LAYER + "LauncherRecentsView:id/overview_panel|TaskView|TextView",
+ DRAG_LAYER
+ + "LauncherAllAppsContainerView:id/apps_view|AllAppsRecyclerView:id"
+ + "/apps_list_view|BubbleTextView:id/icon",
+ DRAG_LAYER + "LauncherDragView|View",
+ CONTENT
+ + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
+ + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
+ + "|ScrollView:id/widget_preview_scroll_view|WidgetCell:id/widget_cell"
+ + "|WidgetCellPreview:id/widget_preview_container|WidgetImageView:id"
+ + "/widget_preview",
+ CONTENT
+ + "AddItemDragLayer:id/add_item_drag_layer|AddItemWidgetsBottomSheet:id"
+ + "/add_item_bottom_sheet|LinearLayout:id/add_item_bottom_sheet_content"
+ + "|ScrollView:id/widget_preview_scroll_view|WidgetCell:id/widget_cell"
+ + "|WidgetCellPreview:id/widget_preview_container|ImageView:id/widget_badge",
+ RECENTS_DRAG_LAYER + "FallbackRecentsView:id/overview_panel|TaskView|IconView:id/icon",
+ DRAG_LAYER
+ + "SearchContainerView:id/apps_view|UniversalSearchInputView:id"
+ + "/search_container_all_apps|View:id/ripple"
+ ));
+
+ // Per-AnalysisNode data that's specific to this detector.
+ private static class NodeData {
+ public boolean ignoreFlashes;
+
+ // 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 flashes, its descendants will too.
+ final boolean parentIgnoresFlashes = info.parent != null && getNodeData(
+ info.parent).ignoreFlashes;
+ if (parentIgnoresFlashes) {
+ nodeData.ignoreFlashes = true;
+ return;
+ }
+
+ // Parent view doesn't ignore flashes.
+ // 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.ignoreFlashes =
+ nodeData.ignoreNode != null && nodeData.ignoreNode.children.isEmpty();
+ }
+
+ @Override
+ String detectAnomalies(AnalysisNode oldInfo, AnalysisNode newInfo, int frameN,
+ long frameTimeNs) {
+ // 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?
+
+ // If this is the first time ever when we see the view, there have been no flashes yet.
+ if (oldInfo == null) return null;
+
+ // A flash requires a view to go from the full visibility to no-visibility and then back,
+ // or vice versa.
+ // If the last time the view was seen before the current frame, it didn't have full
+ // visibility; no flash can possibly be detected at the current frame.
+ if (oldInfo.alpha < 1) return null;
+
+ final AnalysisNode latestInfo = newInfo != null ? newInfo : oldInfo;
+ final NodeData nodeData = getNodeData(latestInfo);
+ if (nodeData.ignoreFlashes) return null;
+
+ // Once the view becomes invisible, see for how long it was visible prior to that. If it
+ // was visible only for a short interval of time, it's a flash.
+ if (
+ // View is invisible in the current frame
+ newInfo == null
+ // When the view became visible last time, it was a transition from
+ // no-visibility to full visibility.
+ && oldInfo.timeBecameVisibleNs != -1) {
+ final long wasVisibleTimeMs = (frameTimeNs - oldInfo.timeBecameVisibleNs) / 1000000;
+
+ if (wasVisibleTimeMs <= FLASH_DURATION_MS) {
+ nodeData.ignoreFlashes = true; // No need to report flashes in children.
+ return
+ String.format(
+ "View was visible for a too short period of time %dms, which is a"
+ + " flash",
+ wasVisibleTimeMs
+ );
+ }
+ }
+
+ // Once a view becomes visible, see for how long it was invisible prior to that. If it
+ // was invisible only for a short interval of time, it's a flash.
+ if (
+ // The view is fully visible now
+ newInfo != null && newInfo.alpha >= 1
+ // The view wasn't visible in the previous frame
+ && frameN != oldInfo.frameN + 1) {
+ // We can assert the below condition because at this point, we know that
+ // oldInfo.alpha >= 1, i.e. it disappeared abruptly.
+ assertTrue("oldInfo.timeBecameInvisibleNs must not be -1",
+ oldInfo.timeBecameInvisibleNs != -1);
+
+ final long wasInvisibleTimeMs = (frameTimeNs - oldInfo.timeBecameInvisibleNs) / 1000000;
+ if (wasInvisibleTimeMs <= FLASH_DURATION_MS) {
+ nodeData.ignoreFlashes = true; // No need to report flashes in children.
+ return
+ String.format(
+ "View was invisible for a too short period of time %dms, which "
+ + "is a flash",
+ wasInvisibleTimeMs);
+ }
+ }
+ 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 949c536..ccb4a1e 100644
--- a/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
+++ b/tests/src/com/android/launcher3/util/viewcapture_analysis/ViewCaptureAnalyzer.java
@@ -17,9 +17,6 @@
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;
@@ -36,40 +33,10 @@
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 {
- // Index of this detector in ViewCaptureAnalyzer.ANOMALY_DETECTORS
- public int detectorOrdinal;
-
- /**
- * 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.
- * @return Anomaly diagnostic message if an anomaly has been detected; null otherwise.
- */
- abstract String detectAnomalies(
- @Nullable AnalysisNode oldInfo, @Nullable AnalysisNode newInfo, int frameN);
- }
-
// All detectors. They will be invoked in the order listed here.
private static final AnomalyDetector[] ANOMALY_DETECTORS = {
- new AlphaJumpDetector()
+ new AlphaJumpDetector(),
+ new FlashDetector()
};
static {
@@ -89,9 +56,21 @@
// Visible scale and alpha, build recursively from the ancestor list.
public float scaleX;
public float scaleY;
- public float alpha;
+ public float alpha; // Always > 0
public int frameN;
+
+ // Timestamp of the frame when this view became abruptly visible, i.e. its alpha became 1
+ // the next frame after it was 0 or the view wasn't visible.
+ // If the view is currently invisible or the last appearance wasn't abrupt, the value is -1.
+ public long timeBecameVisibleNs;
+
+ // Timestamp of the frame when this view became abruptly invisible last time, i.e. its
+ // alpha became 0, or view disappeared, after being 1 in the previous frame.
+ // If the view is currently visible or the last disappearance wasn't abrupt, the value is
+ // -1.
+ public long timeBecameInvisibleNs;
+
public ViewNode viewCaptureNode;
// Class name + resource id
@@ -143,7 +122,9 @@
Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
Map<String, String> anomalies) {
// Analyze the node tree starting from the root.
+ long frameTimeNs = frame.getTimestamp();
analyzeView(
+ frameTimeNs,
frame.getNode(),
/* parent = */ null,
frameN,
@@ -154,7 +135,7 @@
scrimClassIndex,
anomalies);
- // Analyze transitions when a view visible in the last frame become invisible in the
+ // Analyze transitions when a view visible in the previous frame became invisible in the
// current one.
for (AnalysisNode info : lastSeenNodes.values()) {
if (info.frameN == frameN - 1) {
@@ -166,14 +147,18 @@
frameN,
/* oldInfo = */ info,
/* newInfo = */ null,
- anomalies)
+ anomalies,
+ frameTimeNs)
);
}
+ info.timeBecameInvisibleNs = info.alpha == 1 ? frameTimeNs : -1;
+ info.timeBecameVisibleNs = -1;
}
}
}
- private static void analyzeView(ViewNode viewCaptureNode, AnalysisNode parent, int frameN,
+ private static void analyzeView(long frameTimeNs, ViewNode viewCaptureNode, AnalysisNode parent,
+ int frameN,
float leftShift, float topShift, ExportedData viewCaptureData,
Map<Integer, AnalysisNode> lastSeenNodes, int scrimClassIndex,
Map<String, String> anomalies) {
@@ -211,17 +196,31 @@
newAnalysisNode.scaleY = scaleY;
newAnalysisNode.alpha = alpha;
newAnalysisNode.frameN = frameN;
+ newAnalysisNode.timeBecameInvisibleNs = -1;
newAnalysisNode.viewCaptureNode = viewCaptureNode;
Arrays.stream(ANOMALY_DETECTORS).forEach(
detector -> detector.initializeNode(newAnalysisNode));
- // Detect anomalies for the view
final AnalysisNode oldAnalysisNode = lastSeenNodes.get(hashcode); // may be null
+
+ if (oldAnalysisNode != null && oldAnalysisNode.frameN + 1 == frameN) {
+ // If this view was present in the previous frame, keep the time when it became visible.
+ newAnalysisNode.timeBecameVisibleNs = oldAnalysisNode.timeBecameVisibleNs;
+ } else {
+ // If the view is becoming visible after being invisible, initialize the time when it
+ // became visible with a new value.
+ // If the view became visible abruptly, i.e. alpha jumped from 0 to 1 between the
+ // previous and the current frames, then initialize with the time of the current
+ // frame. Otherwise, use -1.
+ newAnalysisNode.timeBecameVisibleNs = newAnalysisNode.alpha >= 1 ? frameTimeNs : -1;
+ }
+
+ // Detect anomalies for the view.
if (frameN != 0 && !viewCaptureNode.getWillNotDraw()) {
Arrays.stream(ANOMALY_DETECTORS).forEach(
detector ->
detectAnomaly(detector, frameN, oldAnalysisNode, newAnalysisNode,
- anomalies)
+ anomalies, frameTimeNs)
);
}
lastSeenNodes.put(hashcode, newAnalysisNode);
@@ -236,20 +235,22 @@
// transparent.
if (child.getClassnameIndex() == scrimClassIndex) break;
- analyzeView(child, newAnalysisNode, frameN, leftShiftForChildren, topShiftForChildren,
+ analyzeView(frameTimeNs, child, newAnalysisNode, frameN, leftShiftForChildren,
+ topShiftForChildren,
viewCaptureData, lastSeenNodes, scrimClassIndex, anomalies);
}
}
private static void detectAnomaly(AnomalyDetector detector, int frameN,
AnalysisNode oldAnalysisNode, AnalysisNode newAnalysisNode,
- Map<String, String> anomalies) {
+ Map<String, String> anomalies, long frameTimeNs) {
final String maybeAnomaly =
- detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN);
+ detector.detectAnomalies(oldAnalysisNode, newAnalysisNode, frameN, frameTimeNs);
if (maybeAnomaly != null) {
- final String viewDiagPath = diagPathFromRoot(newAnalysisNode);
+ AnalysisNode latestInfo = newAnalysisNode != null ? newAnalysisNode : oldAnalysisNode;
+ final String viewDiagPath = diagPathFromRoot(latestInfo);
if (!anomalies.containsKey(viewDiagPath)) {
- anomalies.put(viewDiagPath, maybeAnomaly);
+ anomalies.put(viewDiagPath, String.format("%s, %s", maybeAnomaly, latestInfo));
}
}
}