Add a check for layout nesting depth.

This is to prevent large nested layouts from crashing
SysUI (check the bug for more details).

RelNote: "Limit the maximum depth of a protolayout layout to 30. If a
layout exceeds that limit the ProtoLayoutInstance will throw."
BUG: b/289990187
Test: Added tests.

Change-Id: I8a74b20b153652f324aa31ef9595edfaa72266c4
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java
index 906634e..65f4fa2 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDiffer.java
@@ -272,13 +272,24 @@
         return new LayoutDiff(changedNodes);
     }
 
-    /** Check whether 2 nodes represented by the given fingerprints are equivalent. */
+    /** Check whether two nodes represented by the given fingerprints are equivalent. */
     @RestrictTo(Scope.LIBRARY_GROUP)
     public static boolean areNodesEquivalent(
             @NonNull NodeFingerprint nodeA, @NonNull NodeFingerprint nodeB) {
         return getChangeType(nodeA, nodeB) == NodeChangeType.NO_CHANGE;
     }
 
+    /** Check whether two {@link TreeFingerprint} objects are equivalent. */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    public static boolean areSameFingerprints(
+            @NonNull TreeFingerprint first, @NonNull TreeFingerprint second) {
+        NodeFingerprint prev = first.getRoot();
+        NodeFingerprint current = second.getRoot();
+        return current.getSelfTypeValue() == prev.getSelfTypeValue()
+                && current.getSelfPropsValue() == prev.getSelfPropsValue()
+                && current.getChildNodesValue() == prev.getChildNodesValue();
+    }
+
     private static void addChangedNodes(
             @NonNull NodeFingerprint prevNodeFingerprint,
             @NonNull TreeNode node,
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstance.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstance.java
index 5f53a02..1762d0b 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstance.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstance.java
@@ -1,5 +1,5 @@
 /*
- * Copyright 2021 The Android Open Source Project
+ * Copyright 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.
@@ -39,12 +39,16 @@
 import androidx.wear.protolayout.expression.pipeline.FixedQuotaManagerImpl;
 import androidx.wear.protolayout.expression.pipeline.PlatformDataProvider;
 import androidx.wear.protolayout.expression.pipeline.StateStore;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement.InnerCase;
 import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
+import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
 import androidx.wear.protolayout.proto.ResourceProto;
 import androidx.wear.protolayout.proto.StateProto.State;
 import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider;
 import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
 import androidx.wear.protolayout.renderer.ProtoLayoutVisibilityState;
+import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer;
 import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline;
 import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater;
 import androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater.InflateResult;
@@ -55,12 +59,14 @@
 import androidx.wear.protolayout.renderer.inflater.ResourceResolvers;
 import androidx.wear.protolayout.renderer.inflater.StandardResourceResolvers;
 
+import com.google.common.collect.ImmutableList;
 import com.google.common.collect.ImmutableSet;
 import com.google.common.util.concurrent.Futures;
 import com.google.common.util.concurrent.ListenableFuture;
 import com.google.common.util.concurrent.ListeningExecutorService;
 import com.google.common.util.concurrent.SettableFuture;
 
+import java.util.List;
 import java.util.Map;
 import java.util.Set;
 import java.util.concurrent.CancellationException;
@@ -89,7 +95,7 @@
     }
 
     private static final int DEFAULT_MAX_CONCURRENT_RUNNING_ANIMATIONS = 4;
-
+    static final int MAX_LAYOUT_ELEMENT_DEPTH = 30;
     @NonNull private static final String TAG = "ProtoLayoutViewInstance";
 
     @NonNull private final Context mUiContext;
@@ -134,6 +140,12 @@
     @Nullable private Layout mPrevLayout = null;
 
     /**
+     * This field is used to avoid unnecessarily checking layout depth if the layout was previously
+     * failing the check.
+     */
+    private boolean mPrevLayoutAlreadyFailingDepthCheck = false;
+
+    /**
      * This is used as the Future for the currently running inflation session. The first time
      * "attach" is called, it should start the renderer. Subsequent attach calls should only ever
      * re-attach "inflateParent".
@@ -703,6 +715,21 @@
             return new FailedRenderResult();
         }
 
+        boolean sameFingerprint =
+                prevRenderedMetadata != null
+                        && ProtoLayoutDiffer.areSameFingerprints(
+                                prevRenderedMetadata.getTreeFingerprint(), layout.getFingerprint());
+
+        if (sameFingerprint) {
+            if (mPrevLayoutAlreadyFailingDepthCheck) {
+                throwExceptionForLayoutDepthCheckFailure();
+            }
+        } else {
+            checkLayoutDepth(layout.getRoot(), MAX_LAYOUT_ELEMENT_DEPTH);
+        }
+
+        mPrevLayoutAlreadyFailingDepthCheck = false;
+
         ProtoLayoutInflater.Config.Builder inflaterConfigBuilder =
                 new ProtoLayoutInflater.Config.Builder(mUiContext, layout, resolvers)
                         .setLoadActionExecutor(mUiExecutorService)
@@ -1059,6 +1086,52 @@
         }
     }
 
+    /** Returns true if the layout element depth doesn't exceed the given {@code allowedDepth}. */
+    private void checkLayoutDepth(LayoutElement layoutElement, int allowedDepth) {
+        if (allowedDepth <= 0) {
+            throwExceptionForLayoutDepthCheckFailure();
+        }
+        List<LayoutElement> children = ImmutableList.of();
+        switch (layoutElement.getInnerCase()) {
+            case COLUMN:
+                children = layoutElement.getColumn().getContentsList();
+                break;
+            case ROW:
+                children = layoutElement.getRow().getContentsList();
+                break;
+            case BOX:
+                children = layoutElement.getBox().getContentsList();
+                break;
+            case ARC:
+                List<ArcLayoutElement> arcElements = layoutElement.getArc().getContentsList();
+                if (!arcElements.isEmpty() && allowedDepth == 1) {
+                    throwExceptionForLayoutDepthCheckFailure();
+                }
+                for (ArcLayoutElement element : arcElements) {
+                    if (element.getInnerCase() == InnerCase.ADAPTER) {
+                        checkLayoutDepth(element.getAdapter().getContent(), allowedDepth - 1);
+                    }
+                }
+                break;
+            case SPANNABLE:
+                if (layoutElement.getSpannable().getSpansCount() > 0 && allowedDepth == 1) {
+                    throwExceptionForLayoutDepthCheckFailure();
+                }
+                break;
+            default:
+                // Other LayoutElements have depth of one.
+        }
+        for (LayoutElement child : children) {
+            checkLayoutDepth(child, allowedDepth - 1);
+        }
+    }
+
+    private void throwExceptionForLayoutDepthCheckFailure() {
+        mPrevLayoutAlreadyFailingDepthCheck = true;
+        throw new IllegalStateException(
+                "Layout depth exceeds maximum allowed depth: " + MAX_LAYOUT_ELEMENT_DEPTH);
+    }
+
     @Override
     public void close() throws Exception {
         detachInternal();
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/RenderedMetadata.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/RenderedMetadata.java
index 11cbf345..5b6bdc5 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/RenderedMetadata.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/RenderedMetadata.java
@@ -43,7 +43,7 @@
     }
 
     @NonNull
-    TreeFingerprint getTreeFingerprint() {
+    public TreeFingerprint getTreeFingerprint() {
         return mTreeFingerprint;
     }
 
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java
index b86f294..deb6dbc 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/common/ProtoLayoutDifferTest.java
@@ -303,6 +303,30 @@
     }
 
     @Test
+    public void areSameFingerprints() {
+        assertThat(
+                        ProtoLayoutDiffer.areSameFingerprints(
+                                referenceLayout().getFingerprint(),
+                                referenceLayout().getFingerprint()))
+                .isTrue();
+        assertThat(
+                        ProtoLayoutDiffer.areSameFingerprints(
+                                referenceLayout().getFingerprint(),
+                                layoutWithOneUpdatedNode().getFingerprint()))
+                .isFalse();
+        assertThat(
+                        ProtoLayoutDiffer.areSameFingerprints(
+                                referenceLayout().getFingerprint(),
+                                layoutWithDifferentNumberOfChildren().getFingerprint()))
+                .isFalse();
+        assertThat(
+                        ProtoLayoutDiffer.areSameFingerprints(
+                                referenceLayout().getFingerprint(),
+                                layoutWithUpdateToNodeSelfFingerprint().getFingerprint()))
+                .isFalse();
+    }
+
+    @Test
     public void isChildOf_forAnActualChild_returnsTrue() {
         String childPosId = "pT1.2.3";
         String parentPosId = "pT1.2";
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java
index b158c38..9f7018ae 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/helper/TestDsl.java
@@ -37,6 +37,7 @@
 import androidx.wear.protolayout.proto.FingerprintProto.TreeFingerprint;
 import androidx.wear.protolayout.proto.LayoutElementProto;
 import androidx.wear.protolayout.proto.LayoutElementProto.Arc;
+import androidx.wear.protolayout.proto.LayoutElementProto.ArcAdapter;
 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.ArcText;
 import androidx.wear.protolayout.proto.LayoutElementProto.Box;
@@ -433,6 +434,10 @@
         return arcInternal(/* propsConsumer= */ null, nodes);
     }
 
+    public static LayoutNode arcAdapter(LayoutNode layoutNode) {
+        return arcAdapterInternal(layoutNode);
+    }
+
     private static LayoutNode arcInternal(
             @Nullable Consumer<ArcProps> propsConsumer, LayoutNode... nodes) {
         LayoutNode element = new LayoutNode();
@@ -449,6 +454,15 @@
         return element;
     }
 
+    private static LayoutNode arcAdapterInternal(LayoutNode node) {
+        LayoutNode element = new LayoutNode();
+        ArcAdapter.Builder builder = ArcAdapter.newBuilder().setContent(node.mLayoutElement);
+        int selfPropsFingerprint = 0;
+        element.mArcLayoutElement = ArcLayoutElement.newBuilder().setAdapter(builder.build());
+        element.mFingerprint = fingerprint("arcAdapter", selfPropsFingerprint, node);
+        return element;
+    }
+
     public static LayoutNode arcText(String text) {
         LayoutNode element = new LayoutNode();
         element.mArcLayoutElement =
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstanceTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstanceTest.java
index ca3f94e..3ad5656 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstanceTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/impl/ProtoLayoutViewInstanceTest.java
@@ -17,10 +17,16 @@
 package androidx.wear.protolayout.renderer.impl;
 
 import static androidx.test.core.app.ApplicationProvider.getApplicationContext;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.arc;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.arcAdapter;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.box;
 import static androidx.wear.protolayout.renderer.helper.TestDsl.column;
 import static androidx.wear.protolayout.renderer.helper.TestDsl.dynamicFixedText;
 import static androidx.wear.protolayout.renderer.helper.TestDsl.layout;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.spanText;
+import static androidx.wear.protolayout.renderer.helper.TestDsl.spannable;
 import static androidx.wear.protolayout.renderer.helper.TestDsl.text;
+import static androidx.wear.protolayout.renderer.impl.ProtoLayoutViewInstance.MAX_LAYOUT_ELEMENT_DEPTH;
 
 import static com.google.common.truth.Truth.assertThat;
 
@@ -39,6 +45,7 @@
 import androidx.wear.protolayout.expression.pipeline.StateStore;
 import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
 import androidx.wear.protolayout.proto.ResourceProto.Resources;
+import androidx.wear.protolayout.renderer.helper.TestDsl.LayoutNode;
 import androidx.wear.protolayout.renderer.impl.ProtoLayoutViewInstance.Config;
 
 import com.google.common.collect.ImmutableList;
@@ -55,6 +62,7 @@
 import java.util.ArrayList;
 import java.util.List;
 import java.util.concurrent.AbstractExecutorService;
+import java.util.concurrent.ExecutionException;
 import java.util.concurrent.TimeUnit;
 
 @RunWith(AndroidJUnit4.class)
@@ -390,6 +398,107 @@
         assertThat(mRootContainer.getChildCount()).isEqualTo(0);
     }
 
+    @Test
+    public void layoutDepthExceedsMaximumDepth_renderingFail() throws Exception {
+        setupInstance(/* adaptiveUpdateRatesEnabled= */ false);
+        assertThrows(
+                ExecutionException.class,
+                () -> renderAndAttachLayout(layout(recursiveBox(MAX_LAYOUT_ELEMENT_DEPTH + 1))));
+    }
+
+    @Test
+    public void layoutDepthIsEqualToMaximumDepth_renderingPass() throws Exception {
+        setupInstance(/* adaptiveUpdateRatesEnabled= */ false);
+
+        LayoutNode[] children = new LayoutNode[MAX_LAYOUT_ELEMENT_DEPTH];
+        for (int i = 0; i < children.length; i++) {
+            children[i] = recursiveBox(MAX_LAYOUT_ELEMENT_DEPTH - 1);
+        }
+        ListenableFuture<Void> result =
+                mInstanceUnderTest.renderAndAttach(
+                        // MAX_LAYOUT_ELEMENT_DEPTH branches of depth MAX_LAYOUT_ELEMENT_DEPTH - 1.
+                        // Total depth is MAX_LAYOUT_ELEMENT_DEPTH (if we count the head).
+                        layout(box(children)), RESOURCES, mRootContainer);
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertNoException(result);
+        assertThat(mRootContainer.getChildCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void layoutDepthForLayoutWithSpanner() throws Exception {
+        setupInstance(/* adaptiveUpdateRatesEnabled= */ false);
+
+        assertThrows(
+                ExecutionException.class,
+                () ->
+                        renderAndAttachLayout(
+                                // Total number of views is = MAX_LAYOUT_ELEMENT_DEPTH  + 1 (span
+                                // text)
+                                layout(
+                                        recursiveBox(
+                                                MAX_LAYOUT_ELEMENT_DEPTH,
+                                                spannable(spanText("Hello"))))));
+
+        ListenableFuture<Void> result =
+                mInstanceUnderTest.renderAndAttach(
+                        // Total number of views is = (MAX_LAYOUT_ELEMENT_DEPTH -1)  + 1 (span text)
+                        layout(
+                                recursiveBox(
+                                        MAX_LAYOUT_ELEMENT_DEPTH - 1,
+                                        spannable(spanText("Hello")))),
+                        RESOURCES,
+                        mRootContainer);
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertNoException(result);
+        assertThat(mRootContainer.getChildCount()).isEqualTo(1);
+    }
+
+    @Test
+    public void layoutDepthForLayoutWithArcAdapter() throws Exception {
+        setupInstance(/* adaptiveUpdateRatesEnabled= */ false);
+        assertThrows(
+                ExecutionException.class,
+                () ->
+                        renderAndAttachLayout(
+                                // Total number of views is = 1 (Arc) + (MAX_LAYOUT_ELEMENT_DEPTH)
+                                layout(arc(arcAdapter(recursiveBox(MAX_LAYOUT_ELEMENT_DEPTH))))));
+
+        ListenableFuture<Void> result =
+                mInstanceUnderTest.renderAndAttach(
+                        // Total number of views is = 1 (Arc) + (MAX_LAYOUT_ELEMENT_DEPTH - 1)
+                        // = MAX_LAYOUT_ELEMENT_DEPTH
+                        layout(arc(arcAdapter(recursiveBox(MAX_LAYOUT_ELEMENT_DEPTH - 1)))),
+                        RESOURCES,
+                        mRootContainer);
+        shadowOf(Looper.getMainLooper()).idle();
+
+        assertNoException(result);
+        assertThat(mRootContainer.getChildCount()).isEqualTo(1);
+    }
+
+    private void renderAndAttachLayout(Layout layout) throws Exception {
+        ListenableFuture<Void> result =
+                mInstanceUnderTest.renderAndAttach(layout, RESOURCES, mRootContainer);
+        shadowOf(Looper.getMainLooper()).idle();
+        assertNoException(result);
+    }
+
+    private static LayoutNode recursiveBox(int depth) {
+        if (depth == 1) {
+            return box();
+        }
+        return box(recursiveBox(depth - 1));
+    }
+
+    private static LayoutNode recursiveBox(int depth, LayoutNode leaf) {
+        if (depth == 1) {
+            return leaf;
+        }
+        return box(recursiveBox(depth - 1, leaf));
+    }
+
     private void setupInstance(boolean adaptiveUpdateRatesEnabled) {
         FakeExecutorService uiThreadExecutor =
                 new FakeExecutorService(new Handler(Looper.getMainLooper()));