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()));