Merge "Add an experimental extension layout element" into androidx-main
diff --git a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
index b20db67..0864423 100644
--- a/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout-expression/api/public_plus_experimental_current.txt
@@ -275,6 +275,9 @@
   public static interface DynamicBuilders.DynamicType {
   }
 
+  @RequiresOptIn(level=androidx.annotation.RequiresOptIn.Level.ERROR) @java.lang.annotation.Retention(java.lang.annotation.RetentionPolicy.CLASS) @java.lang.annotation.Target({java.lang.annotation.ElementType.METHOD, java.lang.annotation.ElementType.TYPE, java.lang.annotation.ElementType.FIELD}) public @interface ExperimentalProtoLayoutExtensionApi {
+  }
+
   @androidx.wear.protolayout.expression.ProtoLayoutExperimental public class PlatformHealthSources {
     method @RequiresApi(android.os.Build.VERSION_CODES.Q) @RequiresPermission(android.Manifest.permission.ACTIVITY_RECOGNITION) @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 dailySteps();
     method @RequiresPermission(android.Manifest.permission.BODY_SENSORS) @androidx.wear.protolayout.expression.ProtoLayoutExperimental public static androidx.wear.protolayout.expression.DynamicBuilders.DynamicInt32 heartRateBpm();
diff --git a/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/ExperimentalProtoLayoutExtensionApi.java b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/ExperimentalProtoLayoutExtensionApi.java
new file mode 100644
index 0000000..e2b12f6
--- /dev/null
+++ b/wear/protolayout/protolayout-expression/src/main/java/androidx/wear/protolayout/expression/ExperimentalProtoLayoutExtensionApi.java
@@ -0,0 +1,33 @@
+/*
+ * Copyright 2022 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 androidx.wear.protolayout.expression;
+
+import androidx.annotation.RequiresOptIn;
+import androidx.annotation.RequiresOptIn.Level;
+import java.lang.annotation.ElementType;
+import java.lang.annotation.Retention;
+import java.lang.annotation.RetentionPolicy;
+import java.lang.annotation.Target;
+
+/**
+ * Denotes that this API surface is not usable by default and requires existence of an extension
+ * provider (on the rendering side).
+ */
+@RequiresOptIn(level = Level.ERROR)
+@Retention(RetentionPolicy.CLASS)
+@Target({ElementType.METHOD, ElementType.TYPE, ElementType.FIELD})
+public @interface ExperimentalProtoLayoutExtensionApi {}
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutExtensionViewProvider.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutExtensionViewProvider.java
new file mode 100644
index 0000000..ca4f923
--- /dev/null
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/ProtoLayoutExtensionViewProvider.java
@@ -0,0 +1,41 @@
+package androidx.wear.protolayout.renderer;
+
+/*
+ * 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.
+ * 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.
+ */
+
+import android.view.View;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.annotation.RestrictTo;
+
+/**
+ * View provider for a View ExtensionLayoutElement. This should check that the given renderer
+ * extension ID matches the expected renderer extension ID, then return a View based on the given
+ * payload. The returned View will be measured using the width/height from the {@link
+ * androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement} message.
+ */
+@RestrictTo(RestrictTo.Scope.LIBRARY)
+public interface ProtoLayoutExtensionViewProvider {
+    /**
+     * Return an Android View from the given renderer extension. In case of an error, this method
+     * should return null, and not throw any exceptions.
+     *
+     * <p>Note: The renderer extension must not set the default tag of the returned View object.
+     */
+    @Nullable
+    View provideView(@NonNull byte[] extensionPayload, @NonNull String vendorId);
+}
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 916c3ee..19fafca 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
@@ -40,6 +40,7 @@
 import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
 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.dynamicdata.ProtoLayoutDynamicDataPipeline;
@@ -96,6 +97,8 @@
     @NonNull private final ListeningExecutorService mBgExecutorService;
     @NonNull private final String mClickableIdExtra;
 
+    @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;
+
     private final boolean mAnimationEnabled;
 
     private final boolean mAdaptiveUpdateRatesEnabled;
@@ -299,6 +302,7 @@
         @NonNull private final LoadActionListener mLoadActionListener;
         @NonNull private final ListeningExecutorService mUiExecutorService;
         @NonNull private final ListeningExecutorService mBgExecutorService;
+        @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;
         @NonNull private final String mClickableIdExtra;
         private final boolean mAnimationEnabled;
         private final int mRunningAnimationsLimit;
@@ -317,6 +321,7 @@
                 @NonNull LoadActionListener loadActionListener,
                 @NonNull ListeningExecutorService uiExecutorService,
                 @NonNull ListeningExecutorService bgExecutorService,
+                @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider,
                 @NonNull String clickableIdExtra,
                 boolean animationEnabled,
                 int runningAnimationsLimit,
@@ -332,6 +337,7 @@
             this.mLoadActionListener = loadActionListener;
             this.mUiExecutorService = uiExecutorService;
             this.mBgExecutorService = bgExecutorService;
+            this.mExtensionViewProvider = extensionViewProvider;
             this.mClickableIdExtra = clickableIdExtra;
             this.mAnimationEnabled = animationEnabled;
             this.mRunningAnimationsLimit = runningAnimationsLimit;
@@ -396,6 +402,13 @@
             return mBgExecutorService;
         }
 
+        /** Returns provider for renderer extension. */
+        @RestrictTo(Scope.LIBRARY)
+        @Nullable
+        public ProtoLayoutExtensionViewProvider getExtensionViewProvider() {
+            return mExtensionViewProvider;
+        }
+
         /** Returns extra used for storing clickable id. */
         @NonNull
         public String getClickableIdExtra() {
@@ -444,6 +457,7 @@
             @Nullable private LoadActionListener mLoadActionListener;
             @NonNull private final ListeningExecutorService mUiExecutorService;
             @NonNull private final ListeningExecutorService mBgExecutorService;
+            @Nullable private ProtoLayoutExtensionViewProvider mExtensionViewProvider;
             @NonNull private final String mClickableIdExtra;
             private boolean mAnimationEnabled = true;
             private int mRunningAnimationsLimit = DEFAULT_MAX_CONCURRENT_RUNNING_ANIMATIONS;
@@ -520,6 +534,15 @@
                 return this;
             }
 
+            /** Sets provider for the renderer extension. */
+            @RestrictTo(Scope.LIBRARY)
+            @NonNull
+            public Builder setExtensionViewProvider(
+                    @NonNull ProtoLayoutExtensionViewProvider extensionViewProvider) {
+                this.mExtensionViewProvider = extensionViewProvider;
+                return this;
+            }
+
             /**
              * Sets whether animation are enabled. If disabled, none of the animation will be
              * played.
@@ -596,6 +619,7 @@
                         loadActionListener,
                         mUiExecutorService,
                         mBgExecutorService,
+                        mExtensionViewProvider,
                         mClickableIdExtra,
                         mAnimationEnabled,
                         mRunningAnimationsLimit,
@@ -614,6 +638,7 @@
         this.mLoadActionListener = config.getLoadActionListener();
         this.mUiExecutorService = config.getUiExecutorService();
         this.mBgExecutorService = config.getBgExecutorService();
+        this.mExtensionViewProvider = config.getExtensionViewProvider();
         this.mAnimationEnabled = config.getAnimationEnabled();
         this.mClickableIdExtra = config.getClickableIdExtra();
         this.mAdaptiveUpdateRatesEnabled = config.getAdaptiveUpdateRatesEnabled();
@@ -667,6 +692,10 @@
             inflaterConfigBuilder.setDynamicDataPipeline(mDataPipeline);
         }
 
+        if (mExtensionViewProvider != null) {
+            inflaterConfigBuilder.setExtensionViewProvider(mExtensionViewProvider);
+        }
+
         ProtoLayoutInflater inflater = new ProtoLayoutInflater(inflaterConfigBuilder.build());
 
         // mark the view and skip doing diff update (to avoid doubling the work each time).
diff --git a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
index de2ee97..a50e95d 100644
--- a/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
+++ b/wear/protolayout/protolayout-renderer/src/main/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflater.java
@@ -116,6 +116,7 @@
 import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension;
 import androidx.wear.protolayout.proto.DimensionProto.WrappedDimensionProp;
 import androidx.wear.protolayout.proto.FingerprintProto.NodeFingerprint;
+import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.Arc;
 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.ArcLine;
@@ -161,6 +162,7 @@
 import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger;
 import androidx.wear.protolayout.proto.TriggerProto.Trigger;
 import androidx.wear.protolayout.proto.TypesProto.StringProp;
+import androidx.wear.protolayout.renderer.ProtoLayoutExtensionViewProvider;
 import androidx.wear.protolayout.renderer.ProtoLayoutTheme;
 import androidx.wear.protolayout.renderer.ProtoLayoutTheme.FontSet;
 import androidx.wear.protolayout.renderer.R;
@@ -272,6 +274,8 @@
 
     private final Optional<ProtoLayoutDynamicDataPipeline> mDataPipeline;
 
+    @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;
+
     private final boolean mAllowLayoutChangingBindsWithoutDefault;
     final String mClickableIdExtra;
 
@@ -490,6 +494,7 @@
         @NonNull private final ProtoLayoutTheme mProtoLayoutTheme;
         @Nullable private final ProtoLayoutDynamicDataPipeline mDataPipeline;
         @NonNull private final String mClickableIdExtra;
+        @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;
         private final boolean mAnimationEnabled;
         private final boolean mAllowLayoutChangingBindsWithoutDefault;
 
@@ -502,6 +507,7 @@
                 @NonNull Resources rendererResources,
                 @NonNull ProtoLayoutTheme protoLayoutTheme,
                 @Nullable ProtoLayoutDynamicDataPipeline dataPipeline,
+                @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider,
                 @NonNull String clickableIdExtra,
                 boolean animationEnabled,
                 boolean allowLayoutChangingBindsWithoutDefault) {
@@ -516,6 +522,7 @@
             this.mAnimationEnabled = animationEnabled;
             this.mAllowLayoutChangingBindsWithoutDefault = allowLayoutChangingBindsWithoutDefault;
             this.mClickableIdExtra = clickableIdExtra;
+            this.mExtensionViewProvider = extensionViewProvider;
         }
 
         /** A {@link Context} suitable for interacting with UI. */
@@ -581,6 +588,12 @@
             return mClickableIdExtra;
         }
 
+        /** View provider for the renderer extension. */
+        @Nullable
+        public ProtoLayoutExtensionViewProvider getExtensionViewProvider() {
+            return mExtensionViewProvider;
+        }
+
         /** Whether animation is enabled, which decides whether to load contentUpdateAnimations. */
         public boolean getAnimationEnabled() {
             return mAnimationEnabled;
@@ -609,6 +622,7 @@
             private boolean mAllowLayoutChangingBindsWithoutDefault = false;
             @Nullable private String mClickableIdExtra;
 
+            @Nullable private ProtoLayoutExtensionViewProvider mExtensionViewProvider = null;
             /**
              * @param uiContext A {@link Context} suitable for interacting with UI with.
              * @param layout The layout to be rendered.
@@ -679,6 +693,14 @@
                 return this;
             }
 
+            /** Sets the view provider for the renderer extension. */
+            @NonNull
+            public Builder setExtensionViewProvider(
+                    @NonNull ProtoLayoutExtensionViewProvider extensionViewProvider) {
+                this.mExtensionViewProvider = extensionViewProvider;
+                return this;
+            }
+
             /**
              * Sets whether animation is enabled, which decides whether to load
              * contentUpdateAnimations. Defaults to true.
@@ -738,6 +760,7 @@
                         mRendererResources,
                         checkNotNull(mProtoLayoutTheme),
                         mDataPipeline,
+                        mExtensionViewProvider,
                         checkNotNull(mClickableIdExtra),
                         mAnimationEnabled,
                         mAllowLayoutChangingBindsWithoutDefault);
@@ -763,6 +786,7 @@
         this.mAllowLayoutChangingBindsWithoutDefault =
                 config.getAllowLayoutChangingBindsWithoutDefault();
         this.mClickableIdExtra = config.getClickableIdExtra();
+        this.mExtensionViewProvider = config.getExtensionViewProvider();
     }
 
     private int safeDpToPx(float dp) {
@@ -3152,7 +3176,13 @@
                                 pipelineMaker);
                 break;
             case EXTENSION:
-                // TODO(b/276703002): Add support for vendor extension.
+                try {
+                    inflatedView =
+                            inflateExtension(
+                                    parentViewWrapper, element.getExtension());
+                } catch (IllegalStateException ex) {
+                    Log.w(TAG, "Error inflating Extension.", ex);
+                }
                 break;
             case INNER_NOT_SET:
                 Log.w(TAG, "Unknown child type: " + element.getInnerCase().name());
@@ -3180,6 +3210,56 @@
         return inflatedView;
     }
 
+    @Nullable
+    private InflatedView inflateExtension(
+            ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) {
+        int widthPx = safeDpToPx(element.getWidth().getLinearDimension());
+        int heightPx = safeDpToPx(element.getHeight().getLinearDimension());
+
+        if (widthPx == 0 && heightPx == 0) {
+            return null;
+        }
+
+        if (mExtensionViewProvider == null) {
+            Log.e(TAG, "Layout has extension payload, but no extension provider is available.");
+            return inflateFailedExtension(parentViewWrapper, element);
+        }
+
+        View view =
+                mExtensionViewProvider.provideView(
+                        element.getPayload().toByteArray(), element.getExtensionId());
+
+        if (view == null) {
+            Log.w(TAG, "Extension view provider returned null.");
+            // A failed extension should still occupy space.
+            return inflateFailedExtension(parentViewWrapper, element);
+        }
+
+        if (view.getTag() != null) {
+            throw new IllegalStateException("Extension must not set View's default tag");
+        }
+
+        LayoutParams lp = new LayoutParams(widthPx, heightPx);
+        parentViewWrapper.maybeAddView(view, lp);
+
+        return new InflatedView(
+                view, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp));
+    }
+
+    private InflatedView inflateFailedExtension(
+            ParentViewWrapper parentViewWrapper, ExtensionLayoutElement element) {
+        int widthPx = safeDpToPx(element.getWidth().getLinearDimension());
+        int heightPx = safeDpToPx(element.getHeight().getLinearDimension());
+
+        Space space = new Space(mUiContext);
+
+        LayoutParams lp = new LayoutParams(widthPx, heightPx);
+        parentViewWrapper.maybeAddView(space, lp);
+
+        return new InflatedView(
+                space, parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(lp));
+    }
+
     /**
      * Either yield the constant value stored in stringProp, or register for updates if it is
      * dynamic property.
diff --git a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
index 73fe1a8..4a49fcd 100644
--- a/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
+++ b/wear/protolayout/protolayout-renderer/src/test/java/androidx/wear/protolayout/renderer/inflater/ProtoLayoutInflaterTest.java
@@ -56,6 +56,7 @@
 import android.os.Looper;
 import android.os.SystemClock;
 import android.text.TextUtils.TruncateAt;
+import android.util.Pair;
 import android.view.MotionEvent;
 import android.view.View;
 import android.view.View.MeasureSpec;
@@ -66,6 +67,7 @@
 import android.widget.FrameLayout.LayoutParams;
 import android.widget.ImageView;
 import android.widget.LinearLayout;
+import android.widget.Space;
 import android.widget.TextView;
 
 import androidx.annotation.NonNull;
@@ -115,6 +117,7 @@
 import androidx.wear.protolayout.proto.DimensionProto.DpProp;
 import androidx.wear.protolayout.proto.DimensionProto.ExpandedAngularDimensionProp;
 import androidx.wear.protolayout.proto.DimensionProto.ExpandedDimensionProp;
+import androidx.wear.protolayout.proto.DimensionProto.ExtensionDimension;
 import androidx.wear.protolayout.proto.DimensionProto.ImageDimension;
 import androidx.wear.protolayout.proto.DimensionProto.ProportionalDimensionProp;
 import androidx.wear.protolayout.proto.DimensionProto.SpacerDimension;
@@ -128,6 +131,7 @@
 import androidx.wear.protolayout.proto.LayoutElementProto.Box;
 import androidx.wear.protolayout.proto.LayoutElementProto.ColorFilter;
 import androidx.wear.protolayout.proto.LayoutElementProto.Column;
+import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
 import androidx.wear.protolayout.proto.LayoutElementProto.FontStyle;
 import androidx.wear.protolayout.proto.LayoutElementProto.Image;
 import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
@@ -200,6 +204,7 @@
 import org.robolectric.shadows.ShadowSystemClock;
 
 import java.io.IOException;
+import java.nio.charset.StandardCharsets;
 import java.time.Duration;
 import java.util.ArrayList;
 import java.util.Arrays;
@@ -2348,6 +2353,79 @@
     }
 
     @Test
+    public void inflate_extension_onlySpaceIfNoExtension() {
+        byte[] payload = "Hello World".getBytes(StandardCharsets.UTF_8);
+        int size = 5;
+
+        ExtensionDimension dim =
+                ExtensionDimension.newBuilder().setLinearDimension(dp(size)).build();
+        LayoutElement rootElement =
+                LayoutElement.newBuilder()
+                        .setExtension(
+                                ExtensionLayoutElement.newBuilder()
+                                        .setExtensionId("foo")
+                                        .setPayload(ByteString.copyFrom(payload))
+                                        .setWidth(dim)
+                                        .setHeight(dim))
+                        .build();
+
+        FrameLayout inflatedLayout = renderer(fingerprintedLayout(rootElement)).inflate();
+
+        assertThat(inflatedLayout.getChildCount()).isEqualTo(1);
+        assertThat(inflatedLayout.getChildAt(0)).isInstanceOf(Space.class);
+
+        Space s = (Space) inflatedLayout.getChildAt(0);
+        assertThat(s.getMeasuredWidth()).isEqualTo(size);
+        assertThat(s.getMeasuredHeight()).isEqualTo(size);
+    }
+
+    @Test
+    public void inflate_rendererExtension_withExtension_callsExtension() {
+        List<Pair<byte[], String>> invokedExtensions = new ArrayList<>();
+
+        final byte[] payload = "Hello World".getBytes(StandardCharsets.UTF_8);
+        final int size = 5;
+        final String extensionId = "foo";
+
+        ExtensionDimension dim =
+                ExtensionDimension.newBuilder().setLinearDimension(dp(size)).build();
+        LayoutElement rootElement =
+                LayoutElement.newBuilder()
+                        .setExtension(
+                                ExtensionLayoutElement.newBuilder()
+                                        .setExtensionId(extensionId)
+                                        .setPayload(ByteString.copyFrom(payload))
+                                        .setWidth(dim)
+                                        .setHeight(dim))
+                        .build();
+
+        FrameLayout inflatedLayout =
+                renderer(
+                                newRendererConfigBuilder(fingerprintedLayout(rootElement))
+                                        .setExtensionViewProvider(
+                                                (extensionPayload, id) -> {
+                                                    invokedExtensions.add(
+                                                            new Pair<>(extensionPayload, id));
+                                                    TextView returnedView =
+                                                            new TextView(getApplicationContext());
+                                                    returnedView.setText("testing");
+
+                                                    return returnedView;
+                                                }))
+                        .inflate();
+
+        assertThat(inflatedLayout.getChildCount()).isEqualTo(1);
+        assertThat(inflatedLayout.getChildAt(0)).isInstanceOf(TextView.class);
+
+        TextView tv = (TextView) inflatedLayout.getChildAt(0);
+        assertThat(tv.getText().toString()).isEqualTo("testing");
+
+        assertThat(invokedExtensions).hasSize(1);
+        assertThat(invokedExtensions.get(0).first).isEqualTo(payload);
+        assertThat(invokedExtensions.get(0).second).isEqualTo(extensionId);
+    }
+
+    @Test
     public void inflateThenMutate_withChangeToText_causesUpdate() {
         Layout layout1 =
                 layout(
diff --git a/wear/protolayout/protolayout/api/public_plus_experimental_current.txt b/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
index 5d8554a..4a1e57f 100644
--- a/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
+++ b/wear/protolayout/protolayout/api/public_plus_experimental_current.txt
@@ -224,7 +224,7 @@
     method public androidx.wear.protolayout.DimensionBuilders.DegreesProp.Builder setValue(float);
   }
 
-  public static final class DimensionBuilders.DpProp implements androidx.wear.protolayout.DimensionBuilders.ContainerDimension androidx.wear.protolayout.DimensionBuilders.ImageDimension androidx.wear.protolayout.DimensionBuilders.SpacerDimension {
+  public static final class DimensionBuilders.DpProp implements androidx.wear.protolayout.DimensionBuilders.ContainerDimension androidx.wear.protolayout.DimensionBuilders.ExtensionDimension androidx.wear.protolayout.DimensionBuilders.ImageDimension androidx.wear.protolayout.DimensionBuilders.SpacerDimension {
     method public androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat? getDynamicValue();
     method @Dimension(unit=androidx.annotation.Dimension.DP) public float getValue();
   }
@@ -257,6 +257,9 @@
     method public androidx.wear.protolayout.DimensionBuilders.ExpandedDimensionProp.Builder setLayoutWeight(androidx.wear.protolayout.TypeBuilders.FloatProp);
   }
 
+  @androidx.wear.protolayout.expression.ExperimentalProtoLayoutExtensionApi public static interface DimensionBuilders.ExtensionDimension {
+  }
+
   public static final class DimensionBuilders.HorizontalLayoutConstraint {
     method public int getHorizontalAlignment();
     method @Dimension(unit=androidx.annotation.Dimension.DP) public float getValue();
@@ -537,6 +540,22 @@
     method public androidx.wear.protolayout.LayoutElementBuilders.ContentScaleModeProp.Builder setValue(int);
   }
 
+  @androidx.wear.protolayout.expression.ExperimentalProtoLayoutExtensionApi public static final class LayoutElementBuilders.ExtensionLayoutElement implements androidx.wear.protolayout.LayoutElementBuilders.LayoutElement {
+    method public String getExtensionId();
+    method public androidx.wear.protolayout.DimensionBuilders.ExtensionDimension? getHeight();
+    method public byte[] getPayload();
+    method public androidx.wear.protolayout.DimensionBuilders.ExtensionDimension? getWidth();
+  }
+
+  public static final class LayoutElementBuilders.ExtensionLayoutElement.Builder implements androidx.wear.protolayout.LayoutElementBuilders.LayoutElement.Builder {
+    ctor public LayoutElementBuilders.ExtensionLayoutElement.Builder();
+    method public androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement build();
+    method public androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement.Builder setExtensionId(String);
+    method public androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement.Builder setHeight(androidx.wear.protolayout.DimensionBuilders.ExtensionDimension);
+    method public androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement.Builder setPayload(byte[]);
+    method public androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement.Builder setWidth(androidx.wear.protolayout.DimensionBuilders.ExtensionDimension);
+  }
+
   public static final class LayoutElementBuilders.FontStyle {
     method public androidx.wear.protolayout.ColorBuilders.ColorProp? getColor();
     method public androidx.wear.protolayout.TypeBuilders.BoolProp? getItalic();
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/DimensionBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/DimensionBuilders.java
index fe21d0a..e84479a 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/DimensionBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/DimensionBuilders.java
@@ -24,11 +24,13 @@
 import androidx.annotation.IntRange;
 import androidx.annotation.NonNull;
 import androidx.annotation.Nullable;
+import androidx.annotation.OptIn;
 import androidx.annotation.RestrictTo;
 import androidx.annotation.RestrictTo.Scope;
 import androidx.wear.protolayout.TypeBuilders.FloatProp;
 import androidx.wear.protolayout.expression.DynamicBuilders;
 import androidx.wear.protolayout.expression.DynamicBuilders.DynamicFloat;
+import androidx.wear.protolayout.expression.ExperimentalProtoLayoutExtensionApi;
 import androidx.wear.protolayout.expression.Fingerprint;
 import androidx.wear.protolayout.proto.DimensionProto;
 
@@ -103,13 +105,9 @@
         return WRAP;
     }
 
-    /**
-     * A type for linear dimensions, measured in dp.
-     *
-     * @since 1.0
-     */
+    @OptIn(markerClass = ExperimentalProtoLayoutExtensionApi.class)
     public static final class DpProp
-            implements ContainerDimension, ImageDimension, SpacerDimension {
+            implements ContainerDimension, ImageDimension, SpacerDimension, ExtensionDimension {
         private final DimensionProto.DpProp mImpl;
         @Nullable private final Fingerprint mFingerprint;
 
@@ -191,6 +189,14 @@
         }
 
         @Override
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        @ExperimentalProtoLayoutExtensionApi
+        public DimensionProto.ExtensionDimension toExtensionDimensionProto() {
+            return DimensionProto.ExtensionDimension.newBuilder().setLinearDimension(mImpl).build();
+        }
+
+        @Override
         @NonNull
         public String toString() {
             return "DpProp{" + "value=" + getValue() + ", dynamicValue=" + getDynamicValue() + "}";
@@ -200,7 +206,8 @@
         public static final class Builder
                 implements ContainerDimension.Builder,
                         ImageDimension.Builder,
-                        SpacerDimension.Builder {
+                        SpacerDimension.Builder,
+                        ExtensionDimension.Builder {
             private final DimensionProto.DpProp.Builder mImpl = DimensionProto.DpProp.newBuilder();
             private final Fingerprint mFingerprint = new Fingerprint(756413087);
 
@@ -270,8 +277,8 @@
 
         /**
          * Gets the value to use when laying out components which can have a dynamic value.
-         * Constrains the layout so that components are not changing size or location regardless
-         * of the dynamic value that is being provided.
+         * Constrains the layout so that components are not changing size or location regardless of
+         * the dynamic value that is being provided.
          *
          * @since 1.2
          */
@@ -305,9 +312,8 @@
              * Creates a new builder for {@link DpPropLayoutConstraint}.
              *
              * @param value Sets the value to use when laying out components which can have a
-             *              dynamic value. Constrains the layout so that components are not
-             *              changing size or location regardless of the dynamic value that is
-             *              being provided.
+             *     dynamic value. Constrains the layout so that components are not changing size or
+             *     location regardless of the dynamic value that is being provided.
              * @since 1.2
              */
             protected Builder(@Dimension(unit = DP) float value) {
@@ -316,8 +322,8 @@
 
             /**
              * Sets the value to use when laying out components which can have a dynamic value.
-             * Constrains the layout so that components are not changing size or location
-             * regardless of the dynamic value that is being provided.
+             * Constrains the layout so that components are not changing size or location regardless
+             * of the dynamic value that is being provided.
              *
              * @since 1.2
              */
@@ -362,9 +368,8 @@
              * Creates a new builder for {@link HorizontalLayoutConstraint}.
              *
              * @param value Sets the value to use when laying out components which can have a
-             *              dynamic value. Constrains the layout so that components are not
-             *              changing size or location regardless of the dynamic value that is
-             *              being provided.
+             *     dynamic value. Constrains the layout so that components are not changing size or
+             *     location regardless of the dynamic value that is being provided.
              * @since 1.2
              */
             public Builder(@Dimension(unit = DP) float value) {
@@ -425,9 +430,8 @@
              * Creates a new builder for {@link VerticalLayoutConstraint}.
              *
              * @param value Sets the value to use when laying out components which can have a
-             *              dynamic value. Constrains the layout so that components are not
-             *              changing size or location regardless of the dynamic value that is
-             *              being provided.
+             *     dynamic value. Constrains the layout so that components are not changing size or
+             *     location regardless of the dynamic value that is being provided.
              * @since 1.2
              */
             public Builder(@Dimension(unit = DP) float value) {
@@ -1360,4 +1364,50 @@
     static SpacerDimension spacerDimensionFromProto(@NonNull DimensionProto.SpacerDimension proto) {
         return spacerDimensionFromProto(proto, null);
     }
+
+    /**
+     * Interface defining a dimension that can be applied to a {@link
+     * androidx.wear.protolayout.LayoutElementBuilders.ExtensionLayoutElement} element.
+     *
+     * @since 1.0
+     */
+    @ExperimentalProtoLayoutExtensionApi
+    public interface ExtensionDimension {
+        /** Get the protocol buffer representation of this object. */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        DimensionProto.ExtensionDimension toExtensionDimensionProto();
+
+        /** Get the fingerprint for this object or null if unknown. */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @Nullable
+        Fingerprint getFingerprint();
+
+        /** Builder to create {@link ExtensionDimension} objects. */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        interface Builder {
+
+            /** Builds an instance with values accumulated in this Builder. */
+            @NonNull
+            ExtensionDimension build();
+        }
+    }
+
+    /** Creates a new wrapper instance from the proto. */
+    @RestrictTo(Scope.LIBRARY_GROUP)
+    @NonNull
+    public static ExtensionDimension extensionDimensionFromProto(
+            @NonNull DimensionProto.ExtensionDimension proto, @Nullable Fingerprint fingerprint) {
+        if (proto.hasLinearDimension()) {
+            return DpProp.fromProto(proto.getLinearDimension(), fingerprint);
+        }
+        throw new IllegalStateException(
+                "Proto was not a recognised instance of ExtensionDimension");
+    }
+
+    @NonNull
+    static ExtensionDimension extensionDimensionFromProto(
+            @NonNull DimensionProto.ExtensionDimension proto) {
+        return extensionDimensionFromProto(proto, null);
+    }
 }
diff --git a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
index f97ed264..e3570fd 100644
--- a/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
+++ b/wear/protolayout/protolayout/src/main/java/androidx/wear/protolayout/LayoutElementBuilders.java
@@ -34,6 +34,7 @@
 import androidx.wear.protolayout.DimensionBuilders.DegreesProp;
 import androidx.wear.protolayout.DimensionBuilders.DpProp;
 import androidx.wear.protolayout.DimensionBuilders.EmProp;
+import androidx.wear.protolayout.DimensionBuilders.ExtensionDimension;
 import androidx.wear.protolayout.DimensionBuilders.HorizontalLayoutConstraint;
 import androidx.wear.protolayout.DimensionBuilders.ImageDimension;
 import androidx.wear.protolayout.DimensionBuilders.SpProp;
@@ -46,6 +47,7 @@
 import androidx.wear.protolayout.TypeBuilders.Int32Prop;
 import androidx.wear.protolayout.TypeBuilders.StringLayoutConstraint;
 import androidx.wear.protolayout.TypeBuilders.StringProp;
+import androidx.wear.protolayout.expression.ExperimentalProtoLayoutExtensionApi;
 import androidx.wear.protolayout.expression.Fingerprint;
 import androidx.wear.protolayout.expression.ProtoLayoutExperimental;
 import androidx.wear.protolayout.proto.AlignmentProto;
@@ -54,11 +56,13 @@
 import androidx.wear.protolayout.proto.FingerprintProto.TreeFingerprint;
 import androidx.wear.protolayout.proto.LayoutElementProto;
 import androidx.wear.protolayout.proto.TypesProto;
+import androidx.wear.protolayout.protobuf.ByteString;
 import androidx.wear.protolayout.protobuf.InvalidProtocolBufferException;
 
 import java.lang.annotation.Retention;
 import java.lang.annotation.RetentionPolicy;
 import java.util.ArrayList;
+import java.util.Arrays;
 import java.util.Collections;
 import java.util.List;
 
@@ -4238,8 +4242,204 @@
     }
 
     /**
+     * A layout element which can be defined by a renderer extension. The payload in this message
+     * will be passed verbatim to any registered renderer extension in the renderer. It is then
+     * expected that the extension can parse this message, and emit the relevant element.
+     *
+     * <p>If a renderer extension is not installed, this resource will not render any element,
+     * although the specified space will still be occupied. If the payload cannot be parsed by the
+     * renderer extension, then still nothing should be rendered, although this behaviour is defined
+     * by the renderer extension.
+     *
+     * @since 1.2
+     */
+    @ExperimentalProtoLayoutExtensionApi
+    public static final class ExtensionLayoutElement implements LayoutElement {
+        private final LayoutElementProto.ExtensionLayoutElement mImpl;
+        @Nullable private final Fingerprint mFingerprint;
+
+        ExtensionLayoutElement(
+                LayoutElementProto.ExtensionLayoutElement impl, @Nullable Fingerprint fingerprint) {
+            this.mImpl = impl;
+            this.mFingerprint = fingerprint;
+        }
+
+        /**
+         * Gets the content of the renderer extension element. This can be any data; it is expected
+         * that the renderer extension knows how to parse this field.
+         *
+         * @since 1.2
+         */
+        @NonNull
+        public byte[] getPayload() {
+            return mImpl.getPayload().toByteArray();
+        }
+
+        /**
+         * Gets the ID of the renderer extension that should be used for rendering this layout
+         * element.
+         *
+         * @since 1.2
+         */
+        @NonNull
+        public String getExtensionId() {
+            return mImpl.getExtensionId();
+        }
+
+        /**
+         * Gets the width of this element.
+         *
+         * @since 1.2
+         */
+        @Nullable
+        public ExtensionDimension getWidth() {
+            if (mImpl.hasWidth()) {
+                return DimensionBuilders.extensionDimensionFromProto(mImpl.getWidth());
+            } else {
+                return null;
+            }
+        }
+
+        /**
+         * Gets the height of this element.
+         *
+         * @since 1.2
+         */
+        @Nullable
+        public ExtensionDimension getHeight() {
+            if (mImpl.hasHeight()) {
+                return DimensionBuilders.extensionDimensionFromProto(mImpl.getHeight());
+            } else {
+                return null;
+            }
+        }
+
+        @Override
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @Nullable
+        public Fingerprint getFingerprint() {
+            return mFingerprint;
+        }
+
+        /** Creates a new wrapper instance from the proto. */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public static ExtensionLayoutElement fromProto(
+                @NonNull LayoutElementProto.ExtensionLayoutElement proto,
+                @Nullable Fingerprint fingerprint) {
+            return new ExtensionLayoutElement(proto, fingerprint);
+        }
+
+        @NonNull
+        static ExtensionLayoutElement fromProto(
+                @NonNull LayoutElementProto.ExtensionLayoutElement proto) {
+            return fromProto(proto, null);
+        }
+
+        /** Returns the internal proto instance. */
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        LayoutElementProto.ExtensionLayoutElement toProto() {
+            return mImpl;
+        }
+
+        @Override
+        @RestrictTo(Scope.LIBRARY_GROUP)
+        @NonNull
+        public LayoutElementProto.LayoutElement toLayoutElementProto() {
+            return LayoutElementProto.LayoutElement.newBuilder()
+                    .setExtension(mImpl)
+                    .build();
+        }
+
+        @Override
+        @NonNull
+        public String toString() {
+            return "ExtensionLayoutElement{"
+                    + "payload="
+                    + Arrays.toString(getPayload())
+                    + ", extensionId="
+                    + getExtensionId()
+                    + ", width="
+                    + getWidth()
+                    + ", height="
+                    + getHeight()
+                    + "}";
+        }
+
+        /** Builder for {@link ExtensionLayoutElement}. */
+        public static final class Builder implements LayoutElement.Builder {
+            private final LayoutElementProto.ExtensionLayoutElement.Builder mImpl =
+                    LayoutElementProto.ExtensionLayoutElement.newBuilder();
+            private final Fingerprint mFingerprint = new Fingerprint(661980356);
+
+            public Builder() {}
+
+            /**
+             * Sets the content of the renderer extension element. This can be any data; it is
+             * expected that the renderer extension knows how to parse this field.
+             *
+             * @since 1.2
+             */
+            @NonNull
+            public Builder setPayload(@NonNull byte[] payload) {
+                mImpl.setPayload(ByteString.copyFrom(payload));
+                mFingerprint.recordPropertyUpdate(1, Arrays.hashCode(payload));
+                return this;
+            }
+
+            /**
+             * Sets the ID of the renderer extension that should be used for rendering this layout
+             * element.
+             *
+             * @since 1.2
+             */
+            @NonNull
+            public Builder setExtensionId(@NonNull String extensionId) {
+                mImpl.setExtensionId(extensionId);
+                mFingerprint.recordPropertyUpdate(2, extensionId.hashCode());
+                return this;
+            }
+
+            /**
+             * Sets the width of this element.
+             *
+             * @since 1.2
+             */
+            @NonNull
+            public Builder setWidth(@NonNull ExtensionDimension width) {
+                mImpl.setWidth(width.toExtensionDimensionProto());
+                mFingerprint.recordPropertyUpdate(
+                        3, checkNotNull(width.getFingerprint()).aggregateValueAsInt());
+                return this;
+            }
+
+            /**
+             * Sets the height of this element.
+             *
+             * @since 1.2
+             */
+            @NonNull
+            public Builder setHeight(@NonNull ExtensionDimension height) {
+                mImpl.setHeight(height.toExtensionDimensionProto());
+                mFingerprint.recordPropertyUpdate(
+                        4, checkNotNull(height.getFingerprint()).aggregateValueAsInt());
+                return this;
+            }
+
+            @Override
+            @NonNull
+            public ExtensionLayoutElement build() {
+                return new ExtensionLayoutElement(mImpl.build(), mFingerprint);
+            }
+        }
+    }
+
+    /**
      * Interface defining the root of all layout elements. This exists to act as a holder for all of
      * the actual layout elements above.
+     *
+     * @since 1.0
      */
     public interface LayoutElement {
         /** Get the protocol buffer representation of this object. */
@@ -4265,6 +4465,7 @@
     /** Creates a new wrapper instance from the proto. */
     @RestrictTo(Scope.LIBRARY_GROUP)
     @NonNull
+    @OptIn(markerClass = ExperimentalProtoLayoutExtensionApi.class)
     public static LayoutElement layoutElementFromProto(
             @NonNull LayoutElementProto.LayoutElement proto) {
         if (proto.hasColumn()) {
@@ -4291,6 +4492,9 @@
         if (proto.hasSpannable()) {
             return Spannable.fromProto(proto.getSpannable());
         }
+        if (proto.hasExtension()) {
+            return ExtensionLayoutElement.fromProto(proto.getExtension());
+        }
         throw new IllegalStateException("Proto was not a recognised instance of LayoutElement");
     }
 
@@ -4582,7 +4786,6 @@
      * {@link androidx.wear.protolayout.LayoutElementBuilders.Column}.
      *
      * @since 1.2
-     * @hide
      */
     @RestrictTo(RestrictTo.Scope.LIBRARY)
     @IntDef({