Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit a7a27fd4 authored by Vadim Tryshev's avatar Vadim Tryshev Committed by Android (Google) Code Review
Browse files

Merge "View capture analyzer tree walker + Alpha jump detector V1" into udc-qpr-dev

parents 551cbc49 ad6ebab8
Loading
Loading
Loading
Loading
+91 −0
Original line number Diff line number Diff line
/*
 * 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<String> 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));
        }
    }
}
+238 −0
Original line number Diff line number Diff line
/*
 * 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<AnomalyDetector> 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<Integer, AnalysisNode> 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<Integer, AnalysisNode> 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<Integer, AnalysisNode> 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();
    }
}