public final class

ProtoLayoutInflater

extends java.lang.Object

 java.lang.Object

↳androidx.wear.protolayout.renderer.inflater.ProtoLayoutInflater

Gradle dependencies

compile group: 'androidx.wear.protolayout', name: 'protolayout-renderer', version: '1.2.0'

  • groupId: androidx.wear.protolayout
  • artifactId: protolayout-renderer
  • version: 1.2.0

Artifact androidx.wear.protolayout:protolayout-renderer:1.2.0 it located at Google repository (https://maven.google.com/)

Overview

Renderer for ProtoLayout.

This variant uses Android views to represent the contents of the ProtoLayout.

Summary

Constructors
publicProtoLayoutInflater(ProtoLayoutInflater.Config config)

Methods
public <any>applyMutation(ViewGroup prevInflatedParent, ProtoLayoutInflater.ViewGroupMutation groupMutation)

Apply the mutation that was previously computed with ProtoLayoutInflater.computeMutation(RenderedMetadata, LayoutElementProto.Layout, RenderedMetadata.ViewProperties).

public static IntentbuildLaunchActionIntent(ActionProto.LaunchAction launchAction, java.lang.String clickableId, java.lang.String clickableIdExtra)

Returns an Android that can perform the action defined in the given layout ActionProto.LaunchAction.

public static voidclearRenderedMetadata(ViewGroup inflateParent)

Clears the RenderedMetadata attached to inflateParent.

public ProtoLayoutInflater.ViewGroupMutationcomputeMutation(RenderedMetadata prevRenderedMetadata, LayoutElementProto.Layout targetLayout, RenderedMetadata.ViewProperties parentViewProp)

Compute the mutation that must be applied to the given ViewGroup in order to produce the given target layout.

public static RenderedMetadatagetRenderedMetadata(ViewGroup inflateParent)

Returns the RenderedMetadata attached to inflateParent.

public ProtoLayoutInflater.InflateResultinflate(ViewGroup inflateParent)

Inflates a ProtoLayout into inflateParent.

from java.lang.Objectclone, equals, finalize, getClass, hashCode, notify, notifyAll, toString, wait, wait, wait

Constructors

public ProtoLayoutInflater(ProtoLayoutInflater.Config config)

Methods

public static Intent buildLaunchActionIntent(ActionProto.LaunchAction launchAction, java.lang.String clickableId, java.lang.String clickableIdExtra)

Returns an Android that can perform the action defined in the given layout ActionProto.LaunchAction.

public ProtoLayoutInflater.InflateResult inflate(ViewGroup inflateParent)

Inflates a ProtoLayout into inflateParent.

Parameters:

inflateParent: The view to attach the layout into.

Returns:

The ProtoLayoutInflater.InflateResult class containing the first child that was inflated, animations to be played, and new nodes for the dynamic data pipeline. Callers should use ProtoLayoutInflater.InflateResult.updateDynamicDataPipeline(boolean) to apply those changes using a UI Thread.

This may be null if the proto is empty the top-level LayoutElement has no inner set, or the top-level LayoutElement contains an unsupported inner type.

public ProtoLayoutInflater.ViewGroupMutation computeMutation(RenderedMetadata prevRenderedMetadata, LayoutElementProto.Layout targetLayout, RenderedMetadata.ViewProperties parentViewProp)

Compute the mutation that must be applied to the given ViewGroup in order to produce the given target layout.

If the return value is null, parent must be updated in full using ProtoLayoutInflater.inflate(ViewGroup). Otherwise, call {ViewGroupMutation#isNoOp} on the return value to check if there are any mutations to apply and call ProtoLayoutInflater.applyMutation(ViewGroup, ProtoLayoutInflater.ViewGroupMutation) to apply them.

Can be called from a background thread.

Parameters:

prevRenderedMetadata: The metadata for the previous rendering of this view, either using inflate or applyMutation. This can be retrieved by calling ProtoLayoutInflater.getRenderedMetadata(ViewGroup) on the previous layout view parent.
targetLayout: The target layout that the mutation should result in.

Returns:

The mutation that will produce the target layout.

public <any> applyMutation(ViewGroup prevInflatedParent, ProtoLayoutInflater.ViewGroupMutation groupMutation)

Apply the mutation that was previously computed with ProtoLayoutInflater.computeMutation(RenderedMetadata, LayoutElementProto.Layout, RenderedMetadata.ViewProperties).

public static RenderedMetadata getRenderedMetadata(ViewGroup inflateParent)

Returns the RenderedMetadata attached to inflateParent.

public static void clearRenderedMetadata(ViewGroup inflateParent)

Clears the RenderedMetadata attached to inflateParent.

Source

/*
 * 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.
 */

package androidx.wear.protolayout.renderer.inflater;

import static android.util.TypedValue.COMPLEX_UNIT_SP;
import static android.view.View.INVISIBLE;
import static android.view.View.LAYOUT_DIRECTION_LTR;
import static android.view.View.LAYOUT_DIRECTION_RTL;
import static android.view.View.VISIBLE;

import static androidx.core.util.Preconditions.checkNotNull;
import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.FIRST_CHILD_INDEX;
import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.ROOT_NODE_ID;
import static androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.getParentNodePosId;

import static com.google.common.util.concurrent.Futures.immediateFailedFuture;
import static com.google.common.util.concurrent.Futures.immediateFuture;

import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.lang.Math.round;
import static java.nio.charset.StandardCharsets.US_ASCII;

import android.annotation.SuppressLint;
import android.content.Context;
import android.content.Intent;
import android.content.pm.ActivityInfo;
import android.content.res.ColorStateList;
import android.content.res.Resources;
import android.content.res.Resources.Theme;
import android.graphics.Color;
import android.graphics.Paint.Cap;
import android.graphics.PorterDuff.Mode;
import android.graphics.Rect;
import android.graphics.Typeface;
import android.graphics.drawable.AnimatedVectorDrawable;
import android.graphics.drawable.BitmapDrawable;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.graphics.drawable.GradientDrawable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.TextUtils.TruncateAt;
import android.text.method.LinkMovementMethod;
import android.text.style.AbsoluteSizeSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.StyleSpan;
import android.text.style.UnderlineSpan;
import android.util.Log;
import android.util.TypedValue;
import android.view.ContextThemeWrapper;
import android.view.Gravity;
import android.view.View;
import android.view.ViewGroup;
import android.view.ViewGroup.LayoutParams;
import android.view.ViewOutlineProvider;
import android.view.ViewParent;
import android.view.animation.AlphaAnimation;
import android.view.animation.AnimationSet;
import android.view.animation.TranslateAnimation;
import android.widget.FrameLayout;
import android.widget.ImageView;
import android.widget.ImageView.ScaleType;
import android.widget.LinearLayout;
import android.widget.Scroller;
import android.widget.Space;
import android.widget.TextView;

import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.UiThread;
import androidx.annotation.VisibleForTesting;
import androidx.core.content.ContextCompat;
import androidx.core.view.AccessibilityDelegateCompat;
import androidx.core.view.ViewCompat;
import androidx.core.view.accessibility.AccessibilityNodeInfoCompat;
import androidx.vectordrawable.graphics.drawable.SeekableAnimatedVectorDrawable;
import androidx.wear.protolayout.expression.pipeline.AnimationsHelper;
import androidx.wear.protolayout.expression.proto.AnimationParameterProto.AnimationSpec;
import androidx.wear.protolayout.expression.proto.DynamicProto.DynamicFloat;
import androidx.wear.protolayout.proto.ActionProto.Action;
import androidx.wear.protolayout.proto.ActionProto.AndroidActivity;
import androidx.wear.protolayout.proto.ActionProto.AndroidExtra;
import androidx.wear.protolayout.proto.ActionProto.LaunchAction;
import androidx.wear.protolayout.proto.ActionProto.LoadAction;
import androidx.wear.protolayout.proto.AlignmentProto.AngularAlignment;
import androidx.wear.protolayout.proto.AlignmentProto.ArcAnchorType;
import androidx.wear.protolayout.proto.AlignmentProto.HorizontalAlignment;
import androidx.wear.protolayout.proto.AlignmentProto.TextAlignment;
import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignment;
import androidx.wear.protolayout.proto.AlignmentProto.VerticalAlignmentProp;
import androidx.wear.protolayout.proto.ColorProto.ColorProp;
import androidx.wear.protolayout.proto.DimensionProto.ArcLineLength;
import androidx.wear.protolayout.proto.DimensionProto.ArcSpacerLength;
import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension;
import androidx.wear.protolayout.proto.DimensionProto.ContainerDimension.InnerCase;
import androidx.wear.protolayout.proto.DimensionProto.DegreesProp;
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.PivotDimension;
import androidx.wear.protolayout.proto.DimensionProto.ProportionalDimensionProp;
import androidx.wear.protolayout.proto.DimensionProto.SpProp;
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.Arc;
import androidx.wear.protolayout.proto.LayoutElementProto.ArcDirection;
import androidx.wear.protolayout.proto.LayoutElementProto.ArcLayoutElement;
import androidx.wear.protolayout.proto.LayoutElementProto.ArcLine;
import androidx.wear.protolayout.proto.LayoutElementProto.ArcSpacer;
import androidx.wear.protolayout.proto.LayoutElementProto.ArcText;
import androidx.wear.protolayout.proto.LayoutElementProto.Box;
import androidx.wear.protolayout.proto.LayoutElementProto.Column;
import androidx.wear.protolayout.proto.LayoutElementProto.ContentScaleMode;
import androidx.wear.protolayout.proto.LayoutElementProto.ExtensionLayoutElement;
import androidx.wear.protolayout.proto.LayoutElementProto.FontFeatureSetting;
import androidx.wear.protolayout.proto.LayoutElementProto.FontSetting;
import androidx.wear.protolayout.proto.LayoutElementProto.FontStyle;
import androidx.wear.protolayout.proto.LayoutElementProto.FontVariationSetting;
import androidx.wear.protolayout.proto.LayoutElementProto.Image;
import androidx.wear.protolayout.proto.LayoutElementProto.Layout;
import androidx.wear.protolayout.proto.LayoutElementProto.LayoutElement;
import androidx.wear.protolayout.proto.LayoutElementProto.MarqueeParameters;
import androidx.wear.protolayout.proto.LayoutElementProto.Row;
import androidx.wear.protolayout.proto.LayoutElementProto.Spacer;
import androidx.wear.protolayout.proto.LayoutElementProto.Span;
import androidx.wear.protolayout.proto.LayoutElementProto.SpanImage;
import androidx.wear.protolayout.proto.LayoutElementProto.SpanText;
import androidx.wear.protolayout.proto.LayoutElementProto.SpanVerticalAlignmentProp;
import androidx.wear.protolayout.proto.LayoutElementProto.Spannable;
import androidx.wear.protolayout.proto.LayoutElementProto.StrokeCapProp;
import androidx.wear.protolayout.proto.LayoutElementProto.Text;
import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflow;
import androidx.wear.protolayout.proto.LayoutElementProto.TextOverflowProp;
import androidx.wear.protolayout.proto.ModifiersProto.ArcModifiers;
import androidx.wear.protolayout.proto.ModifiersProto.Background;
import androidx.wear.protolayout.proto.ModifiersProto.Border;
import androidx.wear.protolayout.proto.ModifiersProto.Clickable;
import androidx.wear.protolayout.proto.ModifiersProto.Corner;
import androidx.wear.protolayout.proto.ModifiersProto.CornerRadius;
import androidx.wear.protolayout.proto.ModifiersProto.EnterTransition;
import androidx.wear.protolayout.proto.ModifiersProto.ExitTransition;
import androidx.wear.protolayout.proto.ModifiersProto.FadeInTransition;
import androidx.wear.protolayout.proto.ModifiersProto.FadeOutTransition;
import androidx.wear.protolayout.proto.ModifiersProto.Modifiers;
import androidx.wear.protolayout.proto.ModifiersProto.Padding;
import androidx.wear.protolayout.proto.ModifiersProto.Semantics;
import androidx.wear.protolayout.proto.ModifiersProto.SemanticsRole;
import androidx.wear.protolayout.proto.ModifiersProto.Shadow;
import androidx.wear.protolayout.proto.ModifiersProto.SlideDirection;
import androidx.wear.protolayout.proto.ModifiersProto.SlideInTransition;
import androidx.wear.protolayout.proto.ModifiersProto.SlideOutTransition;
import androidx.wear.protolayout.proto.ModifiersProto.SlideParentSnapOption;
import androidx.wear.protolayout.proto.ModifiersProto.SpanModifiers;
import androidx.wear.protolayout.proto.ModifiersProto.Transformation;
import androidx.wear.protolayout.proto.StateProto.State;
import androidx.wear.protolayout.proto.TriggerProto.OnConditionMetTrigger;
import androidx.wear.protolayout.proto.TriggerProto.OnLoadTrigger;
import androidx.wear.protolayout.proto.TriggerProto.Trigger;
import androidx.wear.protolayout.proto.TypesProto.BoolProp;
import androidx.wear.protolayout.proto.TypesProto.FloatProp;
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;
import androidx.wear.protolayout.renderer.common.LoggingUtils;
import androidx.wear.protolayout.renderer.common.NoOpProviderStatsLogger;
import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer;
import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.LayoutDiff;
import androidx.wear.protolayout.renderer.common.ProtoLayoutDiffer.TreeNodeWithChange;
import androidx.wear.protolayout.renderer.common.ProviderStatsLogger.InflaterStatsLogger;
import androidx.wear.protolayout.renderer.common.RenderingArtifact;
import androidx.wear.protolayout.renderer.dynamicdata.ProtoLayoutDynamicDataPipeline;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LayoutInfo;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.LinearLayoutProperties;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingFrameLayoutParams;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.PendingLayoutParams;
import androidx.wear.protolayout.renderer.inflater.RenderedMetadata.ViewProperties;
import androidx.wear.protolayout.renderer.inflater.ResourceResolvers.ResourceAccessException;
import androidx.wear.widget.ArcLayout;
import androidx.wear.widget.CurvedTextView;

import com.google.common.collect.ImmutableSet;
import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.SettableFuture;

import java.nio.ByteBuffer;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.Locale;
import java.util.Map;
import java.util.Optional;
import java.util.StringJoiner;
import java.util.concurrent.CancellationException;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;
import java.util.function.Consumer;
import java.util.function.Function;

/**
 * Renderer for ProtoLayout.
 *
 * <p>This variant uses Android views to represent the contents of the ProtoLayout.
 */
public final class ProtoLayoutInflater {

    private static final String TAG = "ProtoLayoutInflater";
    private static final char ZERO_WIDTH_JOINER = '\u200D';

    // This will help debug potential layout issues that might be leading to full layout updates or
    // poor performance in general.
    private static final boolean DEBUG_DIFF_UPDATE_ENABLED = false;

    /** Target alpha for fade in animation. */
    private static final float FADE_IN_TARGET_ALPHA = 1;

    /** Initial alpha for fade out animation. */
    private static final float FADE_OUT_INITIAL_ALPHA = 1;

    /** The default trigger for animations set to onLoad. */
    private static final Trigger DEFAULT_ANIMATION_TRIGGER =
            Trigger.newBuilder().setOnLoadTrigger(OnLoadTrigger.getDefaultInstance()).build();

    /** The default minimal click target size, for meeting the accessibility requirement */
    @VisibleForTesting static final float DEFAULT_MIN_CLICKABLE_SIZE_DP = 48f;

    /**
     * Default maximum raw byte size for a bitmap drawable.
     *
     * @see <a
     *     href="https://cs.android.com/android/_/android/platform/frameworks/base/+/d01036ee5893357db577c961119fb85825247f03:graphics/java/android/graphics/RecordingCanvas.java;l=44;bpv=1;bpt=0;drc=00af5271dabd578397176eda0cd7a66c55fac59a">
     *     The framework enforced max size</a>
     */
    private static final int DEFAULT_MAX_BITMAP_RAW_SIZE = 20 * 1024 * 1024;

    private static final int HORIZONTAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_HORIZONTAL;
    private static final int VERTICAL_ALIGN_DEFAULT_GRAVITY = Gravity.CENTER_VERTICAL;
    private static final int TEXT_ALIGN_DEFAULT = Gravity.CENTER_HORIZONTAL;
    private static final ScaleType IMAGE_DEFAULT_SCALE_TYPE = ScaleType.FIT_CENTER;

    @ArcLayout.LayoutParams.VerticalAlignment
    private static final int ARC_VERTICAL_ALIGN_DEFAULT =
            ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER;

    @SizedArcContainer.LayoutParams.AngularAlignment
    private static final int ANGULAR_ALIGNMENT_DEFAULT =
            SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER;

    private static final int SPAN_VERTICAL_ALIGN_DEFAULT = ImageSpan.ALIGN_BOTTOM;

    // This is pretty badly named; TruncateAt specifies where to place the ellipsis (or whether to
    // marquee). Disabling truncation with null actually disables the _ellipsis_, but text will
    // still be truncated.
    @Nullable private static final TruncateAt TEXT_OVERFLOW_DEFAULT = null;

    private static final int TEXT_COLOR_DEFAULT = 0xFFFFFFFF;
    private static final int TEXT_MAX_LINES_DEFAULT = 1;
    @VisibleForTesting static final int TEXT_AUTOSIZES_LIMIT = 10;
    private static final int TEXT_MIN_LINES = 1;

    private static final ContainerDimension CONTAINER_DIMENSION_DEFAULT =
            ContainerDimension.newBuilder()
                    .setWrappedDimension(WrappedDimensionProp.getDefaultInstance())
                    .build();

    @ArcLayout.AnchorType private static final int ARC_ANCHOR_DEFAULT = ArcLayout.ANCHOR_CENTER;

    // White
    private static final int LINE_COLOR_DEFAULT = 0xFFFFFFFF;

    static final PendingLayoutParams NO_OP_PENDING_LAYOUT_PARAMS = layoutParams -> layoutParams;
    private static final byte UNSET_MASK = 0;

    final Context mUiContext;

    // Context wrapped with the provided ProtoLayoutTheme. This context should be used for creating
    // any Text Views, as it will apply text appearance attributes.
    private final Context mProtoLayoutThemeContext;

    private final ProtoLayoutTheme mProtoLayoutTheme;
    private final Layout mLayoutProto;
    private final ResourceResolvers mLayoutResourceResolvers;

    private final Optional<ProtoLayoutDynamicDataPipeline> mDataPipeline;

    @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;

    private final boolean mAllowLayoutChangingBindsWithoutDefault;
    final String mClickableIdExtra;

    @Nullable private final LoggingUtils mLoggingUtils;
    @NonNull private final InflaterStatsLogger mInflaterStatsLogger;
    @Nullable final Executor mLoadActionExecutor;
    final LoadActionListener mLoadActionListener;
    final boolean mAnimationEnabled;

    private boolean mApplyFontVariantBodyAsDefault = false;

    @VisibleForTesting static final String WEIGHT_AXIS_TAG = "wght";
    @VisibleForTesting static final String WIDTH_AXIS_TAG = "wdth";
    @VisibleForTesting static final String TABULAR_OPTION_TAG = "tnum";

    private static final ImmutableSet<String> SUPPORTED_FONT_SETTING_TAGS =
            ImmutableSet.of(WEIGHT_AXIS_TAG, WIDTH_AXIS_TAG, TABULAR_OPTION_TAG);

    /**
     * Listener for clicks on Clickable objects that have an Action to (re)load the contents of a
     * layout.
     */
    public interface LoadActionListener {

        /**
         * Called when a Clickable that has a LoadAction is clicked.
         *
         * @param nextState The state that the next layout should be in.
         */
        void onClick(@NonNull State nextState);
    }

    /**
     * A one-off class to be returned from {@link ProtoLayoutInflater#inflate} containing top level
     * parent, list of content transition animations to be run and a PipelineMaker with pending
     * changes to the dynamic data pipeline.
     */
    public static final class InflateResult {
        public final ViewGroup inflateParent;
        public final View firstChild;
        private final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker;

        InflateResult(
                ViewGroup inflateParent,
                View firstChild,
                Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
            this.inflateParent = inflateParent;
            this.firstChild = firstChild;
            this.mPipelineMaker = pipelineMaker;
        }

        /**
         * Update the DynamicDataPipeline with new nodes that were stored during the layout update.
         *
         * @param isReattaching if True, this layout is being reattached and will skip content
         *     transition animations.
         */
        @UiThread
        public void updateDynamicDataPipeline(boolean isReattaching) {
            mPipelineMaker.ifPresent(
                    pipe -> pipe.clearDataPipelineAndCommit(inflateParent, isReattaching));
        }
    }

    /** A mutation that can be applied to a {@link ViewGroup}, using {@link #applyMutation}. */
    public static final class ViewGroupMutation {
        final List<InflatedView> mInflatedViews;
        final RenderedMetadata mRenderedMetadataAfterMutation;
        final NodeFingerprint mPreMutationRootNodeFingerprint;
        final Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> mPipelineMaker;

        ViewGroupMutation(
                List<InflatedView> inflatedViews,
                RenderedMetadata renderedMetadataAfterMutation,
                NodeFingerprint preMutationRootNodeFingerprint,
                Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
            this.mInflatedViews = inflatedViews;
            this.mRenderedMetadataAfterMutation = renderedMetadataAfterMutation;
            this.mPreMutationRootNodeFingerprint = preMutationRootNodeFingerprint;
            this.mPipelineMaker = pipelineMaker;
        }

        /** Returns true if this mutation has no effect. */
        public boolean isNoOp() {
            return this.mInflatedViews.isEmpty();
        }
    }

    private static final class InflatedView {
        final View mView;
        final LayoutParams mLayoutParams;
        final PendingLayoutParams mChildLayoutParams;
        private int mNumMissingChildren;

        /**
         * @param view The {@link View} that has been inflated.
         * @param layoutParams The {@link LayoutParams} that must be used when attaching the
         *     inflated view to a parent.
         * @param childLayoutParams The {@link LayoutParams} that must be applied to children
         *     carried over from a previous layout.
         * @param numMissingChildren Non-zero if {@code view} is a {@link ViewGroup} whose children
         *     have not been added. This means that before using this view in a layout, its children
         *     must be copied from the {@link ViewGroup} that represents the previous version of
         *     this layout element.
         */
        InflatedView(
                View view,
                LayoutParams layoutParams,
                PendingLayoutParams childLayoutParams,
                int numMissingChildren) {
            this.mView = view;
            this.mLayoutParams = layoutParams;
            this.mChildLayoutParams = childLayoutParams;
            this.mNumMissingChildren = numMissingChildren;
        }

        InflatedView(View view, LayoutParams layoutParams) {
            this(view, layoutParams, NO_OP_PENDING_LAYOUT_PARAMS, /* numMissingChildren= */ 0);
        }

        boolean addMissingChildrenFrom(View source) {
            if (mNumMissingChildren == 0) {
                // Nothing to do.
                return true;
            }
            Object tagObj = source.getTag();
            String tag = (tagObj == null ? "unknown" : (String) tagObj);
            if (!(mView instanceof ViewGroup)) {
                Log.w(TAG, "Destination is not a group: " + tag);
                return false;
            }
            ViewGroup destinationGroup = (ViewGroup) mView;
            if (destinationGroup.getChildCount() > 0) {
                Log.w(TAG, "Destination already has children: " + tag);
                return false;
            }
            if (!(source instanceof ViewGroup)) {
                Log.w(TAG, "Source is not a group: " + tag);
                return false;
            }
            ViewGroup sourceGroup = (ViewGroup) source;
            if (sourceGroup.getChildCount() != mNumMissingChildren) {
                Log.w(
                        TAG,
                        String.format(
                                "Expected %d children in %s found %d",
                                mNumMissingChildren, tag, sourceGroup.getChildCount()));
                return false;
            }
            List<View> children = new ArrayList<>(sourceGroup.getChildCount());
            for (int i = 0; i < mNumMissingChildren; i++) {
                children.add(sourceGroup.getChildAt(i));
            }
            sourceGroup.removeAllViews();

            for (View child : children) {
                destinationGroup.addView(child);
                child.setLayoutParams(
                        mChildLayoutParams.apply(checkNotNull(child.getLayoutParams())));
            }
            mNumMissingChildren = 0;

            if (source.getTouchDelegate() != null) {
                addTouchDelegate(
                        destinationGroup, (TouchDelegateComposite) source.getTouchDelegate());
            }
            return true;
        }

        @Nullable
        String getTag() {
            return (String) mView.getTag();
        }
    }

    /**
     * A one-of class to pass either a real {@link ViewGroup} or only its needed properties through
     * the renderer.
     */
    private static final class ParentViewWrapper {
        @Nullable private final ViewGroup mParent;
        private final ViewProperties mParentProps;

        ParentViewWrapper(ViewGroup parent, LayoutParams parentLayoutParams) {
            this.mParent = parent;
            this.mParentProps =
                    ViewProperties.fromViewGroup(
                            parent, parentLayoutParams, NO_OP_PENDING_LAYOUT_PARAMS);
        }

        ParentViewWrapper(
                ViewGroup parent,
                LayoutParams parentLayoutParams,
                PendingLayoutParams childLayoutParams) {
            this.mParent = parent;
            this.mParentProps =
                    ViewProperties.fromViewGroup(parent, parentLayoutParams, childLayoutParams);
        }

        ParentViewWrapper(ViewProperties parentProps) {
            this.mParent = null;
            this.mParentProps = parentProps;
        }

        ViewProperties getParentProperties() {
            return mParentProps;
        }

        /** If this class holds a {@link ViewGroup}, add {@code child} to it. */
        void maybeAddView(View child, LayoutParams layoutParams) {
            if (mParent != null) {
                mParent.addView(child, layoutParams);
            }
        }
    }

    /** Exception that will be thrown when applying a mutation to a {@link View} fails. */
    public static class ViewMutationException extends RuntimeException {
        public ViewMutationException(@NonNull String message) {
            super(message);
        }
    }

    /** Config class for ProtoLayoutInflater */
    public static final class Config {
        public static final String DEFAULT_CLICKABLE_ID_EXTRA =
                "androidx.wear.protolayout.extra.CLICKABLE_ID";
        @NonNull private final Context mUiContext;
        @NonNull private final Layout mLayout;
        @NonNull private final ResourceResolvers mLayoutResourceResolvers;
        @Nullable private final Executor mLoadActionExecutor;
        @NonNull private final LoadActionListener mLoadActionListener;
        @NonNull private final Resources mRendererResources;
        @NonNull private final ProtoLayoutTheme mProtoLayoutTheme;
        @Nullable private final ProtoLayoutDynamicDataPipeline mDataPipeline;
        @NonNull private final String mClickableIdExtra;

        @Nullable private final LoggingUtils mLoggingUtils;
        @NonNull private final InflaterStatsLogger mInflaterStatsLogger;
        @Nullable private final ProtoLayoutExtensionViewProvider mExtensionViewProvider;
        private final boolean mAnimationEnabled;

        private final boolean mAllowLayoutChangingBindsWithoutDefault;

        private final boolean mApplyFontVariantBodyAsDefault;

        Config(
                @NonNull Context uiContext,
                @NonNull Layout layout,
                @NonNull ResourceResolvers layoutResourceResolvers,
                @Nullable Executor loadActionExecutor,
                @NonNull LoadActionListener loadActionListener,
                @NonNull Resources rendererResources,
                @NonNull ProtoLayoutTheme protoLayoutTheme,
                @Nullable ProtoLayoutDynamicDataPipeline dataPipeline,
                @Nullable ProtoLayoutExtensionViewProvider extensionViewProvider,
                @NonNull String clickableIdExtra,
                @Nullable LoggingUtils loggingUtils,
                @NonNull InflaterStatsLogger inflaterStatsLogger,
                boolean animationEnabled,
                boolean allowLayoutChangingBindsWithoutDefault,
                boolean applyFontVariantBodyAsDefault) {
            this.mUiContext = uiContext;
            this.mLayout = layout;
            this.mLayoutResourceResolvers = layoutResourceResolvers;
            this.mLoadActionExecutor = loadActionExecutor;
            this.mLoadActionListener = loadActionListener;
            this.mRendererResources = rendererResources;
            this.mProtoLayoutTheme = protoLayoutTheme;
            this.mDataPipeline = dataPipeline;
            this.mAnimationEnabled = animationEnabled;
            this.mAllowLayoutChangingBindsWithoutDefault = allowLayoutChangingBindsWithoutDefault;
            this.mClickableIdExtra = clickableIdExtra;
            this.mLoggingUtils = loggingUtils;
            this.mInflaterStatsLogger = inflaterStatsLogger;
            this.mExtensionViewProvider = extensionViewProvider;
            this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault;
        }

        /** A {@link Context} suitable for interacting with UI. */
        @NonNull
        public Context getUiContext() {
            return mUiContext;
        }

        /** The layout to be rendered. */
        @NonNull
        public Layout getLayout() {
            return mLayout;
        }

        /** Resolvers for the resources used for rendering this layout. */
        @NonNull
        public ResourceResolvers getLayoutResourceResolvers() {
            return mLayoutResourceResolvers;
        }

        /** Executor to dispatch loadActionListener on. */
        @Nullable
        public Executor getLoadActionExecutor() {
            return mLoadActionExecutor;
        }

        /** Listener for clicks that will cause contents to be reloaded. */
        @NonNull
        public LoadActionListener getLoadActionListener() {
            return mLoadActionListener;
        }

        /**
         * Renderer internal resources. This Resources object can be used to resolve Renderer's
         * resources.
         */
        @NonNull
        public Resources getRendererResources() {
            return mRendererResources;
        }

        /**
         * Theme to use for this ProtoLayoutInflater instance. This can be used to customise things
         * like the default font family.
         */
        @NonNull
        public ProtoLayoutTheme getProtoLayoutTheme() {
            return mProtoLayoutTheme;
        }

        /**
         * Pipeline for dynamic data. If null, the dynamic properties would not be registered for
         * update.
         */
        @Nullable
        public ProtoLayoutDynamicDataPipeline getDynamicDataPipeline() {
            return mDataPipeline;
        }

        /**
         * ID for the Intent extra containing the ID of a Clickable. Defaults to {@link
         * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified.
         */
        @NonNull
        public String getClickableIdExtra() {
            return mClickableIdExtra;
        }

        /** Debug logger used to log debug messages. */
        @Nullable
        public LoggingUtils getLoggingUtils() {
            return mLoggingUtils;
        }

        /** Stats logger used for telemetry. */
        @NonNull
        public InflaterStatsLogger getInflaterStatsLogger() {
            return mInflaterStatsLogger;
        }

        /** 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;
        }

        /**
         * Whether a "layout changing" data bind can be applied without the "value_for_layout" field
         * being filled in. This is to support legacy apps which use layout-changing data binds
         * before the full support was built.
         */
        public boolean getAllowLayoutChangingBindsWithoutDefault() {
            return mAllowLayoutChangingBindsWithoutDefault;
        }

        /** Whether to apply FONT_VARIANT_BODY as default variant. */
        public boolean getApplyFontVariantBodyAsDefault() {
            return mApplyFontVariantBodyAsDefault;
        }

        /** Builder for the Config class. */
        public static final class Builder {
            @NonNull private final Context mUiContext;
            @NonNull private final Layout mLayout;
            @NonNull private final ResourceResolvers mLayoutResourceResolvers;
            @Nullable private Executor mLoadActionExecutor;
            @Nullable private LoadActionListener mLoadActionListener;
            @NonNull private Resources mRendererResources;
            @Nullable private ProtoLayoutTheme mProtoLayoutTheme;
            @Nullable private ProtoLayoutDynamicDataPipeline mDataPipeline = null;
            private boolean mAnimationEnabled = true;
            private boolean mAllowLayoutChangingBindsWithoutDefault = false;
            @Nullable private String mClickableIdExtra;

            @Nullable private LoggingUtils mLoggingUtils;
            @Nullable private InflaterStatsLogger mInflaterStatsLogger;

            @Nullable private ProtoLayoutExtensionViewProvider mExtensionViewProvider = null;

            private boolean mApplyFontVariantBodyAsDefault = false;

            /**
             * @param uiContext A {@link Context} suitable for interacting with UI with.
             * @param layout The layout to be rendered.
             * @param layoutResourceResolvers Resolvers for the resources used for rendering this
             *     layout.
             */
            public Builder(
                    @NonNull Context uiContext,
                    @NonNull Layout layout,
                    @NonNull ResourceResolvers layoutResourceResolvers) {
                this.mUiContext = uiContext;
                this.mRendererResources = uiContext.getResources();
                this.mLayout = layout;
                this.mLayoutResourceResolvers = layoutResourceResolvers;
            }

            /**
             * Sets the Executor to dispatch loadActionListener on. This is required when setting
             * {@link Builder#setLoadActionListener}.
             */
            @NonNull
            public Builder setLoadActionExecutor(@NonNull Executor loadActionExecutor) {
                this.mLoadActionExecutor = loadActionExecutor;
                return this;
            }

            /**
             * Sets the listener for clicks that will cause contents to be reloaded. Defaults to
             * no-op. This is required if the given layout contains a load action. When this is set,
             * it's also required to set an executor with {@link Builder#setLoadActionExecutor}.
             */
            @NonNull
            public Builder setLoadActionListener(@NonNull LoadActionListener loadActionListener) {
                this.mLoadActionListener = loadActionListener;
                return this;
            }

            /**
             * Sets the Renderer internal Resources object. This should be specified when loading
             * the renderer from a separate APK. This can usually be retrieved with {@link
             * android.content.pm.PackageManager#getResourcesForApplication(String)}. If not
             * specified, this is retrieved from the Ui Context.
             */
            @NonNull
            public Builder setRendererResources(@NonNull Resources rendererResources) {
                this.mRendererResources = rendererResources;
                return this;
            }

            /**
             * Sets the theme to use for this ProtoLayoutInflater instance. This can be used to
             * customise things like the default font family. If not set, the default theme is used.
             */
            @NonNull
            public Builder setProtoLayoutTheme(@NonNull ProtoLayoutTheme protoLayoutTheme) {
                this.mProtoLayoutTheme = protoLayoutTheme;
                return this;
            }

            /**
             * Sets the pipeline for dynamic data. If null, the dynamic properties would not be
             * registered for update.
             */
            @NonNull
            public Builder setDynamicDataPipeline(
                    @NonNull ProtoLayoutDynamicDataPipeline dataPipeline) {
                this.mDataPipeline = dataPipeline;
                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.
             */
            @NonNull
            public Builder setAnimationEnabled(boolean animationEnabled) {
                this.mAnimationEnabled = animationEnabled;
                return this;
            }

            /**
             * Sets the ID for the Intent extra containing the ID of a Clickable. Defaults to {@link
             * Config#DEFAULT_CLICKABLE_ID_EXTRA} if not specified.
             */
            @NonNull
            public Builder setClickableIdExtra(@NonNull String clickableIdExtra) {
                this.mClickableIdExtra = clickableIdExtra;
                return this;
            }

            /** Sets the debug logger used for extensive logging. */
            @NonNull
            public Builder setLoggingUtils(@NonNull LoggingUtils loggingUtils) {
                this.mLoggingUtils = loggingUtils;
                return this;
            }

            /** Sets the stats logger used for telemetry. */
            @NonNull
            public Builder setInflaterStatsLogger(
                    @NonNull InflaterStatsLogger inflaterStatsLogger) {
                this.mInflaterStatsLogger = inflaterStatsLogger;
                return this;
            }

            /**
             * Sets whether a "layout changing" data bind can be applied without the
             * "value_for_layout" field being filled in, or being set to zero / empty. Defaults to
             * false.
             *
             * <p>This is to support legacy apps which use layout-changing data bind before the full
             * support was built.
             */
            @NonNull
            public Builder setAllowLayoutChangingBindsWithoutDefault(
                    boolean allowLayoutChangingBindsWithoutDefault) {
                this.mAllowLayoutChangingBindsWithoutDefault =
                        allowLayoutChangingBindsWithoutDefault;
                return this;
            }

            /** Apply FONT_VARIANT_BODY as default variant. */
            @NonNull
            public Builder setApplyFontVariantBodyAsDefault(boolean applyFontVariantBodyAsDefault) {
                this.mApplyFontVariantBodyAsDefault = applyFontVariantBodyAsDefault;
                return this;
            }

            /** Builds a Config instance. */
            @NonNull
            public Config build() {
                if (mLoadActionListener != null && mLoadActionExecutor == null) {
                    throw new IllegalArgumentException(
                            "A loadActionExecutor should always be set if setting a"
                                    + " loadActionListener.");
                } else if (mLoadActionListener == null && mLoadActionExecutor != null) {
                    throw new IllegalArgumentException(
                            "A loadActionExecutor has been provided but no loadActionListener was"
                                    + " set.");
                }

                if (mLoadActionListener == null) {
                    mLoadActionListener = p -> {};
                }
                if (mProtoLayoutTheme == null) {
                    this.mProtoLayoutTheme = ProtoLayoutThemeImpl.defaultTheme(mUiContext);
                }

                if (mClickableIdExtra == null) {
                    mClickableIdExtra = DEFAULT_CLICKABLE_ID_EXTRA;
                }
                if (mInflaterStatsLogger == null) {
                    mInflaterStatsLogger =
                            new NoOpProviderStatsLogger("No implementation was provided")
                                    .createInflaterStatsLogger();
                }
                return new Config(
                        mUiContext,
                        mLayout,
                        mLayoutResourceResolvers,
                        mLoadActionExecutor,
                        checkNotNull(mLoadActionListener),
                        mRendererResources,
                        checkNotNull(mProtoLayoutTheme),
                        mDataPipeline,
                        mExtensionViewProvider,
                        checkNotNull(mClickableIdExtra),
                        mLoggingUtils,
                        mInflaterStatsLogger,
                        mAnimationEnabled,
                        mAllowLayoutChangingBindsWithoutDefault,
                        mApplyFontVariantBodyAsDefault);
            }
        }
    }

    public ProtoLayoutInflater(@NonNull Config config) {
        // Wrap the Ui Context with a Theme from rendererResources, so that any implicit resource
        // reads using the R class from this package are successful.
        Theme rendererTheme = config.getRendererResources().newTheme();
        rendererTheme.setTo(config.getUiContext().getTheme());
        this.mUiContext = new ContextThemeWrapper(config.getUiContext(), rendererTheme);
        this.mProtoLayoutTheme = config.getProtoLayoutTheme();
        this.mProtoLayoutThemeContext =
                new ContextThemeWrapper(mUiContext, mProtoLayoutTheme.getTheme());
        this.mLayoutProto = config.getLayout();
        this.mLayoutResourceResolvers = config.getLayoutResourceResolvers();
        this.mLoadActionExecutor = config.getLoadActionExecutor();
        this.mLoadActionListener = config.getLoadActionListener();
        this.mDataPipeline = Optional.ofNullable(config.getDynamicDataPipeline());
        this.mAnimationEnabled = config.getAnimationEnabled();
        this.mAllowLayoutChangingBindsWithoutDefault =
                config.getAllowLayoutChangingBindsWithoutDefault();
        this.mClickableIdExtra = config.getClickableIdExtra();
        this.mLoggingUtils = config.getLoggingUtils();
        this.mInflaterStatsLogger = config.getInflaterStatsLogger();
        this.mExtensionViewProvider = config.getExtensionViewProvider();
        this.mApplyFontVariantBodyAsDefault = config.getApplyFontVariantBodyAsDefault();
    }

    private int dpToPx(float dp) {
        return round(dp * mUiContext.getResources().getDisplayMetrics().density);
    }

    private int safeDpToPx(float dp) {
        return max(0, dpToPx(dp));
    }

    private int safeDpToPx(DpProp dpProp) {
        return safeDpToPx(dpProp.getValue());
    }

    @Nullable
    private static Float safeAspectRatioOrNull(
            ProportionalDimensionProp proportionalDimensionProp) {
        final int dividend = proportionalDimensionProp.getAspectRatioWidth();
        final int divisor = proportionalDimensionProp.getAspectRatioHeight();

        if (dividend <= 0 || divisor <= 0) {
            return null;
        }
        return (float) dividend / divisor;
    }

    private static Rect getSourceBounds(View v) {
        final int[] pos = new int[2];
        v.getLocationOnScreen(pos);

        return new Rect(
                /* left= */ pos[0],
                /* top= */ pos[1],
                /* right= */ pos[0] + v.getWidth(),
                /* bottom= */ pos[1] + v.getHeight());
    }

    /**
     * Generates a generic LayoutParameters for use by all components. This just defaults to setting
     * the width/height to WRAP_CONTENT.
     *
     * @return The default layout parameters.
     */
    private static LayoutParams generateDefaultLayoutParams() {
        return new LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    // dereference of possibly-null reference parent.getLayoutParams()
    @SuppressWarnings("nullness:dereference.of.nullable")
    private LayoutParams updateLayoutParamsInLinearLayout(
            LinearLayoutProperties linearLayoutProperties,
            LayoutParams layoutParams,
            ContainerDimension width,
            ContainerDimension height) {
        // This is a little bit fun. ProtoLayout's semantics is that dimension = expand should eat
        // all remaining space in that dimension, but not grow the parent. This is easy for standard
        // containers, but a little trickier in rows and columns on Android.
        //
        // A Row (LinearLayout) supports this with width=0 and weight>0. After doing a layout pass,
        // it will assign all remaining space to elements with width=0 and weight>0, biased by the
        // weight. This causes problems if there are two (or more) "expand" elements in a row, which
        // is itself set to WRAP_CONTENTS, and one of those elements has a measured width (e.g.
        // Text). In that case, the LinearLayout will measure the text, then ensure that all
        // elements with a weight set have their widths set according to the weight. For us, that
        // means that _all_ elements with expand=true will size themselves to the same width as the
        // Text, pushing out the bounds of the parent row. This happens on columns too, but of
        // course regarding height.
        //
        // To get around this, if an element with expand=true is added to a row that is WRAP_CONTENT
        // (e.g. a row with no explicit width, that is not expanded), we ignore the expand=true, and
        // set the inner element's width to WRAP_CONTENT too.

        LinearLayout.LayoutParams linearLayoutParams = new LinearLayout.LayoutParams(layoutParams);
        LayoutParams parentLayoutParams = linearLayoutProperties.getRawLayoutParams();

        // Handle the width
        if (linearLayoutProperties.getOrientation() == LinearLayout.HORIZONTAL
                && width.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
            // If the parent container would not normally have "remaining space", ignore the
            // expand=true.
            if (parentLayoutParams.width == LayoutParams.WRAP_CONTENT) {
                linearLayoutParams.width = LayoutParams.WRAP_CONTENT;
            } else {
                linearLayoutParams.width = 0;
                float weight = width.getExpandedDimension().getLayoutWeight().getValue();
                linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f;
            }
        } else {
            linearLayoutParams.width = dimensionToPx(width);
        }

        // And the height
        if (linearLayoutProperties.getOrientation() == LinearLayout.VERTICAL
                && height.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
            // If the parent container would not normally have "remaining space", ignore the
            // expand=true.
            if (parentLayoutParams.height == LayoutParams.WRAP_CONTENT) {
                linearLayoutParams.height = LayoutParams.WRAP_CONTENT;
            } else {
                linearLayoutParams.height = 0;
                float weight = height.getExpandedDimension().getLayoutWeight().getValue();
                linearLayoutParams.weight = weight != 0.0f ? weight : 1.0f;
            }
        } else {
            linearLayoutParams.height = dimensionToPx(height);
        }

        return linearLayoutParams;
    }

    /**
     * Creates {@link ContainerDimension} from the given {@link SpacerDimension}. If none of the
     * linear or expanded dimension are present, it defaults to linear dimension 0.
     */
    private static ContainerDimension spacerDimensionToContainerDimension(
            SpacerDimension spacerDimension) {
        ContainerDimension.Builder containerDimension =
                ContainerDimension.newBuilder().setLinearDimension(DpProp.newBuilder().setValue(0));
        if (spacerDimension.hasLinearDimension()) {
            containerDimension.setLinearDimension(spacerDimension.getLinearDimension());
        } else if (spacerDimension.hasExpandedDimension()) {
            containerDimension.setExpandedDimension(spacerDimension.getExpandedDimension());
        }
        return containerDimension.build();
    }

    private LayoutParams updateLayoutParams(
            ViewProperties viewProperties,
            LayoutParams layoutParams,
            ContainerDimension width,
            ContainerDimension height) {
        if (viewProperties instanceof LinearLayoutProperties) {
            // LinearLayouts have a bunch of messy caveats in ProtoLayout when their children can be
            // expanded; factor that case out to keep this clean.
            return updateLayoutParamsInLinearLayout(
                    (LinearLayoutProperties) viewProperties, layoutParams, width, height);
        } else {
            layoutParams.width = dimensionToPx(width);
            layoutParams.height = dimensionToPx(height);
        }

        return layoutParams;
    }

    private void resolveMinimumDimensions(
            View view, ContainerDimension width, ContainerDimension height) {
        if (width.getWrappedDimension().hasMinimumSize()) {
            view.setMinimumWidth(safeDpToPx(width.getWrappedDimension().getMinimumSize()));
        }

        if (height.getWrappedDimension().hasMinimumSize()) {
            view.setMinimumHeight(safeDpToPx(height.getWrappedDimension().getMinimumSize()));
        }
    }

    @VisibleForTesting()
    static int getFrameLayoutGravity(
            HorizontalAlignment horizontalAlignment, VerticalAlignment verticalAlignment) {
        return horizontalAlignmentToGravity(horizontalAlignment)
                | verticalAlignmentToGravity(verticalAlignment);
    }

    @SuppressLint("RtlHardcoded")
    private static int horizontalAlignmentToGravity(HorizontalAlignment alignment) {
        switch (alignment) {
            case HORIZONTAL_ALIGN_START:
                return Gravity.START;
            case HORIZONTAL_ALIGN_CENTER:
                return Gravity.CENTER_HORIZONTAL;
            case HORIZONTAL_ALIGN_END:
                return Gravity.END;
            case HORIZONTAL_ALIGN_LEFT:
                return Gravity.LEFT;
            case HORIZONTAL_ALIGN_RIGHT:
                return Gravity.RIGHT;
            case UNRECOGNIZED:
            case HORIZONTAL_ALIGN_UNDEFINED:
                return HORIZONTAL_ALIGN_DEFAULT_GRAVITY;
        }

        return HORIZONTAL_ALIGN_DEFAULT_GRAVITY;
    }

    private static int verticalAlignmentToGravity(VerticalAlignment alignment) {
        switch (alignment) {
            case VERTICAL_ALIGN_TOP:
                return Gravity.TOP;
            case VERTICAL_ALIGN_CENTER:
                return Gravity.CENTER_VERTICAL;
            case VERTICAL_ALIGN_BOTTOM:
                return Gravity.BOTTOM;
            case UNRECOGNIZED:
            case VERTICAL_ALIGN_UNDEFINED:
                return VERTICAL_ALIGN_DEFAULT_GRAVITY;
        }

        return VERTICAL_ALIGN_DEFAULT_GRAVITY;
    }

    @ArcLayout.LayoutParams.VerticalAlignment
    private static int verticalAlignmentToArcVAlign(VerticalAlignmentProp alignment) {
        switch (alignment.getValue()) {
            case VERTICAL_ALIGN_TOP:
                return ArcLayout.LayoutParams.VERTICAL_ALIGN_OUTER;
            case VERTICAL_ALIGN_CENTER:
                return ArcLayout.LayoutParams.VERTICAL_ALIGN_CENTER;
            case VERTICAL_ALIGN_BOTTOM:
                return ArcLayout.LayoutParams.VERTICAL_ALIGN_INNER;
            case UNRECOGNIZED:
            case VERTICAL_ALIGN_UNDEFINED:
                return ARC_VERTICAL_ALIGN_DEFAULT;
        }

        return ARC_VERTICAL_ALIGN_DEFAULT;
    }

    private static ScaleType contentScaleModeToScaleType(ContentScaleMode contentScaleMode) {
        switch (contentScaleMode) {
            case CONTENT_SCALE_MODE_FIT:
                return ScaleType.FIT_CENTER;
            case CONTENT_SCALE_MODE_CROP:
                return ScaleType.CENTER_CROP;
            case CONTENT_SCALE_MODE_FILL_BOUNDS:
                return ScaleType.FIT_XY;
            case CONTENT_SCALE_MODE_UNDEFINED:
            case UNRECOGNIZED:
                return IMAGE_DEFAULT_SCALE_TYPE;
        }

        return IMAGE_DEFAULT_SCALE_TYPE;
    }

    private static int spanVerticalAlignmentToImgSpanAlignment(
            SpanVerticalAlignmentProp alignment) {
        switch (alignment.getValue()) {
            case SPAN_VERTICAL_ALIGN_TEXT_BASELINE:
                return ImageSpan.ALIGN_BASELINE;
            case SPAN_VERTICAL_ALIGN_BOTTOM:
                return ImageSpan.ALIGN_BOTTOM;
            case SPAN_VERTICAL_ALIGN_UNDEFINED:
            case UNRECOGNIZED:
                return SPAN_VERTICAL_ALIGN_DEFAULT;
        }

        return SPAN_VERTICAL_ALIGN_DEFAULT;
    }

    /**
     * Whether a font style is bold or not (has weight > 700). Note that this check is required,
     * even if you are using an explicitly bold font (e.g. Roboto-Bold), as Typeface still needs to
     * bold bit set to render properly.
     */
    private static boolean isBold(FontStyle fontStyle) {
        // Even though we have weight axis too, this concept of bold and bold flag in Typeface is
        // different, so we only look at the FontWeight enum API here.
        // Although this method could be a simple equality check against FONT_WEIGHT_BOLD, we list
        // all current cases here so that this will become a compile time error as soon as a new
        // FontWeight value is added to the schema. If this fails to build, then this means that an
        // int typeface style is no longer enough to represent all FontWeight values and a
        // customizable, per-weight text style must be introduced to ProtoLayoutInflater to handle
        // this. See b/176980535
        switch (fontStyle.getWeight().getValue()) {
            case FONT_WEIGHT_BOLD:
                return true;
            case FONT_WEIGHT_NORMAL:
            case FONT_WEIGHT_MEDIUM:
            case FONT_WEIGHT_UNDEFINED:
            case UNRECOGNIZED:
                return false;
        }

        return false;
    }

    private Typeface fontStyleToTypeface(FontStyle fontStyle) {
        String[] preferredFontFamilies = new String[fontStyle.getPreferredFontFamiliesCount() + 1];
        if (fontStyle.getPreferredFontFamiliesCount() > 0) {
            fontStyle.getPreferredFontFamiliesList().toArray(preferredFontFamilies);
        }

        // Add value from the FontVariant as fallback to work with providers using legacy
        // FONT_VARIANT API or with older system API.
        String fontVariantName;
        switch (fontStyle.getVariant().getValue()) {
            case FONT_VARIANT_TITLE:
                fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_TITLE;
                break;
            case UNRECOGNIZED:
            case FONT_VARIANT_BODY:
            default:
                // fall through as this is default
                fontVariantName = ProtoLayoutTheme.FONT_NAME_LEGACY_VARIANT_BODY;
                break;
        }
        preferredFontFamilies[preferredFontFamilies.length - 1] = fontVariantName;

        FontSet fonts = mProtoLayoutTheme.getFontSet(preferredFontFamilies);

        // Only use FontWeight enum API. Weight axis is already covered by FontVariationSetting.
        switch (fontStyle.getWeight().getValue()) {
            case FONT_WEIGHT_BOLD:
                return fonts.getBoldFont();
            case FONT_WEIGHT_MEDIUM:
                return fonts.getMediumFont();
            case FONT_WEIGHT_NORMAL:
            case FONT_WEIGHT_UNDEFINED:
            case UNRECOGNIZED:
                return fonts.getNormalFont();
        }

        return fonts.getNormalFont();
    }

    private static int fontStyleToTypefaceStyle(FontStyle fontStyle) {
        final boolean isBold = isBold(fontStyle);
        final boolean isItalic = fontStyle.getItalic().getValue();

        if (isBold && isItalic) {
            return Typeface.BOLD_ITALIC;
        } else if (isBold) {
            return Typeface.BOLD;
        } else if (isItalic) {
            return Typeface.ITALIC;
        } else {
            return Typeface.NORMAL;
        }
    }

    @SuppressWarnings("nullness")
    private Typeface createTypeface(FontStyle fontStyle) {
        return Typeface.create(fontStyleToTypeface(fontStyle), fontStyleToTypefaceStyle(fontStyle));
    }

    /**
     * Returns whether or not the default style bits in Typeface can be used, or if we need to add
     * bold/italic flags there.
     */
    private static boolean hasDefaultTypefaceStyle(FontStyle fontStyle) {
        return !fontStyle.getItalic().getValue() && !isBold(fontStyle);
    }

    private float toPx(SpProp spField) {
        return TypedValue.applyDimension(
                COMPLEX_UNIT_SP, spField.getValue(), mUiContext.getResources().getDisplayMetrics());
    }

    private void applyFontStyle(
            FontStyle style,
            TextView textView,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker,
            boolean isAutoSizeAllowed) {
        // Note: Underline must be applied as a Span to work correctly (as opposed to using
        // TextPaint#setTextUnderline). This is applied in the caller instead.

        // Need to supply typefaceStyle when creating the typeface (will select specialist
        // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic
        // flags in Paint if they're not supported by the given typeface).
        // This is fine to do even for variable fonts with weight axis, as if their weight axis is
        // larger than 700 and BOLD flag is on, it should be "bolded" two times - one for Typeface
        // selection, one for axis value.
        textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style));

        if (fontStyleHasSize(style)) {
            List<SpProp> sizes = style.getSizeList();
            int sizesCnt = sizes.size();

            if (sizesCnt == 1) {
                // No autosizing needed.
                textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(0).getValue());
            } else if (isAutoSizeAllowed && sizesCnt <= TEXT_AUTOSIZES_LIMIT) {
                // We need to check values so that we are certain that there's at least 1 non zero
                // value.
                boolean atLeastOneCorrectSize =
                        sizes.stream()
                                        .mapToInt(sp -> (int) sp.getValue())
                                        .filter(sp -> sp > 0)
                                        .distinct()
                                        .count()
                                > 0;

                if (atLeastOneCorrectSize) {
                    // Max size is needed so that TextView leaves enough space for it. Otherwise,
                    // the text won't be able to grow.
                    int maxSize =
                            sizes.stream().mapToInt(sp -> (int) sp.getValue()).max().getAsInt();
                    textView.setTextSize(COMPLEX_UNIT_SP, maxSize);

                    // No need for sorting, TextView does that.
                    textView.setAutoSizeTextTypeUniformWithPresetSizes(
                            sizes.stream().mapToInt(spProp -> (int) spProp.getValue()).toArray(),
                            COMPLEX_UNIT_SP);
                } else {
                    Log.w(
                            TAG,
                            "Trying to autosize text but no valid font sizes has been specified.");
                }
            } else {
                // Fallback where multiple values can't be used and the last value would be used.
                // This can happen in two cases.
                if (!isAutoSizeAllowed) {
                    // There is more than 1 size specified, but autosizing is not allowed.
                    Log.w(
                            TAG,
                            "Trying to autosize text with multiple font sizes where it's not "
                                    + "allowed. Ignoring all other sizes and using the last one.");
                } else {
                    Log.w(
                            TAG,
                            "More than "
                                    + TEXT_AUTOSIZES_LIMIT
                                    + " sizes has been added for the "
                                    + "text autosizing. Ignoring all other sizes and using the last"
                                    + "one.");
                }

                textView.setTextSize(COMPLEX_UNIT_SP, sizes.get(sizesCnt - 1).getValue());
            }
        }

        if (style.hasLetterSpacing()) {
            textView.setLetterSpacing(style.getLetterSpacing().getValue());
        }

        if (style.hasColor()) {
            handleProp(style.getColor(), textView::setTextColor, posId, pipelineMaker);
        } else {
            textView.setTextColor(TEXT_COLOR_DEFAULT);
        }

        if (style.getSettingsCount() > 0) {
            applyFontSetting(style, textView);
        }
    }

    private void applyFontSetting(@NonNull FontStyle style, @NonNull TextView textView) {
        StringJoiner variationSettings = new StringJoiner(",");
        StringJoiner featureSettings = new StringJoiner(",");

        for (FontSetting setting : style.getSettingsList()) {
            String tag = "";

            switch (setting.getInnerCase()) {
                case VARIATION:
                    FontVariationSetting variation = setting.getVariation();
                    tag = toTagString(variation.getAxisTag());

                    if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) {
                        variationSettings.add("'" + tag + "' " + variation.getValue());
                    } else {
                        // Skip not supported tags.
                        Log.d(TAG, "FontVariation axes tag " + tag + " is not supported.");
                    }

                    break;
                case FEATURE:
                    FontFeatureSetting feature = setting.getFeature();
                    tag = toTagString(feature.getTag());

                    if (SUPPORTED_FONT_SETTING_TAGS.contains(tag)) {
                        featureSettings.add("'" + tag + "'");
                    } else {
                        // Skip not supported tags.
                        Log.d(TAG, "FontFeature tag " + tag + " is not supported.");
                    }

                    break;
                case INNER_NOT_SET:
                    break;
            }
        }

        textView.setFontVariationSettings(variationSettings.toString());
        textView.setFontFeatureSettings(featureSettings.toString());
    }

    /** Given the integer representation of 4 characters ASCII code, returns the String of it. */
    @NonNull
    private static String toTagString(int tagCode) {
        return new String(ByteBuffer.allocate(4).putInt(tagCode).array(), US_ASCII);
    }

    private void applyFontStyle(FontStyle style, CurvedTextView textView) {
        // Need to supply typefaceStyle when creating the typeface (will select specialist
        // bold/italic typefaces), *and* when setting the typeface (will set synthetic bold/italic
        // flags in Paint if they're not supported by the given typeface).
        textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style));

        if (fontStyleHasSize(style)) {
            // We are using the last added size in the FontStyle because ArcText doesn't support
            // autosizing. This is the same behaviour as it was before size has made repeated.
            if (style.getSizeList().size() > 1) {
                Log.w(
                        TAG,
                        "Font size with multiple values has been used on Arc Text. Ignoring "
                                + "all size except the first one.");
            }
            textView.setTextSize(toPx(style.getSize(style.getSizeCount() - 1)));
        }
    }

    void dispatchLaunchActionIntent(Intent i) {
        ActivityInfo ai = i.resolveActivityInfo(mUiContext.getPackageManager(), /* flags= */ 0);

        if (ai != null && ai.exported && (ai.permission == null || ai.permission.isEmpty())) {
            mUiContext.startActivity(i);
        }
    }

    private void applyClickable(
            @NonNull View view,
            @Nullable View wrapper,
            @NonNull Clickable clickable,
            boolean extendTouchTarget) {
        view.setTag(R.id.clickable_id_tag, clickable.getId());

        boolean hasAction = false;
        switch (clickable.getOnClick().getValueCase()) {
            case LAUNCH_ACTION:
                Intent i =
                        buildLaunchActionIntent(
                                clickable.getOnClick().getLaunchAction(),
                                clickable.getId(),
                                mClickableIdExtra);
                if (i != null) {
                    hasAction = true;
                    view.setOnClickListener(
                            v -> {
                                i.setSourceBounds(getSourceBounds(view));
                                dispatchLaunchActionIntent(i);
                            });
                }
                break;
            case LOAD_ACTION:
                hasAction = true;
                if (mLoadActionExecutor == null) {
                    Log.w(TAG, "Ignoring load action since an executor has not been provided.");
                    break;
                }
                view.setOnClickListener(
                        v ->
                                checkNotNull(mLoadActionExecutor)
                                        .execute(
                                                () -> {
                                                    Log.d(
                                                            TAG,
                                                            "Executing LoadAction listener"
                                                                    + " with clickable id: "
                                                                    + clickable.getId());
                                                    mLoadActionListener.onClick(
                                                            buildState(
                                                                    clickable
                                                                            .getOnClick()
                                                                            .getLoadAction(),
                                                                    clickable.getId()));
                                                }));
                break;
            case VALUE_NOT_SET:
                break;
        }

        if (hasAction) {
            if (!clickable.hasVisualFeedbackEnabled() || clickable.getVisualFeedbackEnabled()) {
                // Apply ripple effect
                applyRippleEffect(view);
            }

            if (!extendTouchTarget) {
                // For temporarily disable the touch size check for element on arc
                return;
            }

            view.post(
                    () -> {
                        // Use the logical parent (the parent in the proto) for the touch delegation
                        // 1. When the direct parent is the wrapper view for layout sizing, it could
                        // no provide extra space around the view.
                        // 2. the ancestor view further up in the hierarchy are also NOT used, even
                        // when the logical parent might not have enough space around the view.
                        // Below are the considerations:
                        //  a. The clickables of the same logical parent should have same priority
                        //   of touch delegations. Only with this, we could avoid breaking the
                        //   rule of propagating touch event upwards in order. The higher the
                        //   ancester which forwards the touch event, the later the event is been
                        //   propagated to.
                        //  b. The minimal clickable size is not layout affecting, so unreasonable
                        //    big touch target size should not be guaranteed which leads to
                        //    unpredictable behavior. With it limited within the logical parent
                        //    bounds, the behaviour is predictable.
                        ViewGroup logicalParent;
                        if (wrapper != null) {
                            logicalParent = (ViewGroup) wrapper.getParent();
                        } else {
                            logicalParent = (ViewGroup) view.getParent();
                        }
                        if (logicalParent != null) {
                            float widthDp = DEFAULT_MIN_CLICKABLE_SIZE_DP;
                            float heightDp = DEFAULT_MIN_CLICKABLE_SIZE_DP;
                            if (clickable.hasMinimumClickableWidth()) {
                                widthDp = clickable.getMinimumClickableWidth().getValue();
                            }
                            if (clickable.hasMinimumClickableHeight()) {
                                heightDp = clickable.getMinimumClickableHeight().getValue();
                            }
                            extendClickableAreaIfNeeded(
                                    view, logicalParent, safeDpToPx(widthDp), safeDpToPx(heightDp));
                        }
                    });
        }
    }

    private void applyRippleEffect(@NonNull View view) {
        if (mProtoLayoutTheme.getRippleResId() != 0) {
            try {
                view.setForeground(
                        mProtoLayoutTheme
                                .getTheme()
                                .getDrawable(mProtoLayoutTheme.getRippleResId()));
                return;
            } catch (Resources.NotFoundException e) {
                Log.e(
                        TAG,
                        "Could not resolve the provided ripple resource id from the theme, "
                                + "fallback to use the system default ripple.");
            }
        }

        // Use the system default ripple effect by resolving selectableItemBackground against the
        // mUiContext theme, which provides the drawable.
        TypedValue outValue = new TypedValue();
        boolean isValid =
                mUiContext
                        .getTheme()
                        .resolveAttribute(
                                android.R.attr.selectableItemBackground,
                                outValue,
                                /* resolveRefs= */ true);
        if (isValid) {
            view.setForeground(mUiContext.getDrawable(outValue.resourceId));
        } else {
            Log.e(
                    TAG,
                    "Could not resolve android.R.attr.selectableItemBackground from Ui Context.");
        }
    }

    /*
     * Attempt to extend the clickable area if the view bound falls below the required minimum
     * clickable width/height. The clickable area is extended by delegating the touch event of its
     * surrounding space from its logical parent. Note that, the view's direct parent might not
     * be the logical parent in the proto. For example, ImageView is wrapped in a RatioViewWrapper
     */
    private static void extendClickableAreaIfNeeded(
            @NonNull View view,
            @NonNull ViewGroup logicalParent,
            int minClickableWidthPx,
            int minClickableHeightPx) {
        Rect hitRect = new Rect();
        view.getHitRect(hitRect);
        if (minClickableWidthPx > hitRect.width() || minClickableHeightPx > hitRect.height()) {

            ViewGroup directParent = (ViewGroup) view.getParent();
            while (logicalParent != directParent) {
                if (directParent == null) {
                    return;
                }
                Rect rect = new Rect();
                directParent.getHitRect(rect);
                hitRect.offset(rect.left, rect.top);
                directParent = (ViewGroup) directParent.getParent();
            }

            Rect actualRect = new Rect(hitRect);
            // Negative inset makes the rect wider.
            hitRect.inset(
                    min(0, hitRect.width() - minClickableWidthPx) / 2,
                    min(0, hitRect.height() - minClickableHeightPx) / 2);

            addTouchDelegate(logicalParent, new TouchDelegateComposite(view, actualRect, hitRect));
        }
    }

    private static void addTouchDelegate(
            @NonNull View parent, @NonNull TouchDelegateComposite touchDelegateComposite) {
        TouchDelegateComposite touchDelegate;
        if (parent.getTouchDelegate() != null) {
            touchDelegate = (TouchDelegateComposite) parent.getTouchDelegate();
            touchDelegate.mergeFrom(touchDelegateComposite);
        } else {
            touchDelegate = touchDelegateComposite;
        }
        parent.setTouchDelegate(touchDelegate);
    }

    private void applyPadding(View view, Padding padding) {
        if (padding.getRtlAware().getValue()) {
            view.setPaddingRelative(
                    safeDpToPx(padding.getStart()),
                    safeDpToPx(padding.getTop()),
                    safeDpToPx(padding.getEnd()),
                    safeDpToPx(padding.getBottom()));
        } else {
            view.setPadding(
                    safeDpToPx(padding.getStart()),
                    safeDpToPx(padding.getTop()),
                    safeDpToPx(padding.getEnd()),
                    safeDpToPx(padding.getBottom()));
        }
    }

    private GradientDrawable applyBackground(
            View view,
            Background background,
            @Nullable GradientDrawable drawable,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (drawable == null) {
            drawable = new GradientDrawable();
        }

        if (background.hasColor()) {
            handleProp(background.getColor(), drawable::setColor, posId, pipelineMaker);
        }

        applyCornerToBackground(view, background, drawable);

        return drawable;
    }

    private void applyCornerToBackground(
            View view, @NonNull Background background, @NonNull GradientDrawable drawable) {
        if (!background.hasCorner()) {
            return;
        }

        // apply corner
        final Corner corner = background.getCorner();
        final int radiusPx = corner.hasRadius() ? safeDpToPx(corner.getRadius()) : 0;
        float[] radii = new float[8];
        Arrays.fill(radii, radiusPx);
        if (corner.hasTopLeftRadius()) {
            setCornerRadiusToArray(corner.getTopLeftRadius(), radii, /* index= */ 0);
        }
        if (corner.hasTopRightRadius()) {
            setCornerRadiusToArray(corner.getTopRightRadius(), radii, /* index= */ 2);
        }
        if (corner.hasBottomRightRadius()) {
            setCornerRadiusToArray(corner.getBottomRightRadius(), radii, /* index= */ 4);
        }
        if (corner.hasBottomLeftRadius()) {
            setCornerRadiusToArray(corner.getBottomLeftRadius(), radii, /* index= */ 6);
        }

        if (areAllEqual(radii, /* count= */ 8)) {
            if (radii[0] == 0) {
                return;
            }
            // The implementation in GradientDrawable is more efficient by calling setCornerRadius
            // than calling setCornerRadii with an array of all equal items.
            drawable.setCornerRadius(radii[0]);
        } else {
            drawable.setCornerRadii(radii);
        }

        view.setClipToOutline(true);
        view.setOutlineProvider(ViewOutlineProvider.BACKGROUND);
    }

    private void setCornerRadiusToArray(
            @NonNull CornerRadius cornerRadius, float[] radii, int index) {
        if (cornerRadius.hasX()) {
            radii[index] = safeDpToPx(cornerRadius.getX());
        }
        if (cornerRadius.hasY()) {
            radii[index + 1] = safeDpToPx(cornerRadius.getY());
        }
    }

    private static boolean areAllEqual(float[] array, int count) {
        for (int i = 1; i < count; i++) {
            if (array[0] != array[i]) {
                return false;
            }
        }
        return true;
    }

    private GradientDrawable applyBorder(
            Border border,
            @Nullable GradientDrawable drawable,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (drawable == null) {
            drawable = new GradientDrawable();
        }

        GradientDrawable finalDrawable = drawable;
        int width = safeDpToPx(border.getWidth());
        handleProp(
                border.getColor(),
                borderColor -> finalDrawable.setStroke(width, borderColor),
                posId,
                pipelineMaker);

        return drawable;
    }

    private void applyTransformation(
            @NonNull View view,
            @NonNull Transformation transformation,
            @NonNull String posId,
            @NonNull Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        // In a composite transformation, the order of applying the individual transformations
        // does not affect the result, as Android view does the transformation in fixed order by
        // first scale, then rotate then translate.

        if (transformation.hasTranslationX()) {
            handleProp(
                    transformation.getTranslationX(),
                    translationX -> view.setTranslationX(dpToPx(translationX)),
                    posId,
                    pipelineMaker);
        }

        if (transformation.hasTranslationY()) {
            handleProp(
                    transformation.getTranslationY(),
                    translationY -> view.setTranslationY(dpToPx(translationY)),
                    posId,
                    pipelineMaker);
        }

        if (transformation.hasScaleX()) {
            handleProp(transformation.getScaleX(), view::setScaleX, posId, pipelineMaker);
        }

        if (transformation.hasScaleY()) {
            handleProp(transformation.getScaleY(), view::setScaleY, posId, pipelineMaker);
        }

        if (transformation.hasRotation()) {
            handleProp(transformation.getRotation(), view::setRotation, posId, pipelineMaker);
        }

        if (transformation.hasPivotX()) {
            applyTransformationPivot(
                    view,
                    transformation.getPivotX(),
                    offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.X),
                    locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.X),
                    posId,
                    pipelineMaker);
        }

        if (transformation.hasPivotY()) {
            applyTransformationPivot(
                    view,
                    transformation.getPivotY(),
                    offsetDp -> setPivotInOffsetDp(view, offsetDp, PivotType.Y),
                    locationRatio -> setPivotInLocationRatio(view, locationRatio, PivotType.Y),
                    posId,
                    pipelineMaker);
        }
    }

    private enum PivotType {
        X,
        Y
    }

    private void setPivotInOffsetDp(View view, float pivot, PivotType type) {
        if (type == PivotType.X) {
            view.setPivotX(dpToPx(pivot) + view.getWidth() * 0.5f);
        } else {
            view.setPivotY(dpToPx(pivot) + view.getHeight() * 0.5f);
        }
    }

    private void setPivotInLocationRatio(View view, float pivot, PivotType type) {
        if (type == PivotType.X) {
            view.setPivotX(pivot * view.getWidth());
        } else {
            view.setPivotY(pivot * view.getHeight());
        }
    }

    private void applyTransformationPivot(
            View view,
            PivotDimension pivotDimension,
            Consumer<Float> consumerOffsetDp,
            Consumer<Float> consumerLocationRatio,
            @NonNull String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        switch (pivotDimension.getInnerCase()) {
            case OFFSET_DP:
                DpProp offset = pivotDimension.getOffsetDp();
                handleProp(
                        offset,
                        value -> view.post(() -> consumerOffsetDp.accept(value)),
                        consumerOffsetDp,
                        posId,
                        pipelineMaker);
                break;
            case LOCATION_RATIO:
                FloatProp ratio = pivotDimension.getLocationRatio().getRatio();
                handleProp(
                        ratio,
                        value -> view.post(() -> consumerLocationRatio.accept(value)),
                        consumerLocationRatio,
                        posId,
                        pipelineMaker);
                break;
            case INNER_NOT_SET:
                Log.w(
                        TAG,
                        "PivotDimension has an unknown dimension type: "
                                + pivotDimension.getInnerCase().name());
        }
    }

    private View applyModifiers(
            @NonNull View view,
            @Nullable View wrapper, // The wrapper view for layout sizing, if any
            @NonNull Modifiers modifiers,
            @NonNull String posId,
            @NonNull Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (modifiers.hasVisible()) {
            applyVisible(
                    view,
                    modifiers.getVisible(),
                    posId,
                    pipelineMaker,
                    visible -> visible ? VISIBLE : INVISIBLE);
        } else if (modifiers.hasHidden()) {
            // This is a deprecated field
            applyVisible(
                    view,
                    modifiers.getHidden(),
                    posId,
                    pipelineMaker,
                    hidden -> hidden ? INVISIBLE : VISIBLE);
        }

        if (modifiers.hasClickable()) {
            applyClickable(view, wrapper, modifiers.getClickable(), /* extendTouchTarget= */ true);
        }

        if (modifiers.hasSemantics()) {
            applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker);
        }

        if (modifiers.hasPadding()) {
            applyPadding(view, modifiers.getPadding());
        }

        GradientDrawable backgroundDrawable = null;

        if (modifiers.hasBackground()) {
            backgroundDrawable =
                    applyBackground(
                            view,
                            modifiers.getBackground(),
                            backgroundDrawable,
                            posId,
                            pipelineMaker);
        }

        if (modifiers.hasBorder()) {
            backgroundDrawable =
                    applyBorder(modifiers.getBorder(), backgroundDrawable, posId, pipelineMaker);
        }

        if (backgroundDrawable != null) {
            view.setBackground(backgroundDrawable);
        }

        if (mAnimationEnabled && modifiers.hasContentUpdateAnimation()) {
            pipelineMaker.ifPresent(
                    p ->
                            p.storeAnimatedVisibilityFor(
                                    posId, modifiers.getContentUpdateAnimation()));
        }

        if (modifiers.hasTransformation()) {
            applyTransformation(
                    wrapper == null ? view : wrapper,
                    modifiers.getTransformation(),
                    posId,
                    pipelineMaker);
        }

        if (modifiers.hasOpacity()) {
            handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker);
        }

        return view;
    }

    private void applyVisible(
            View view,
            BoolProp visible,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker,
            Function<Boolean, Integer> toViewVisibility) {
        handleProp(
                visible,
                visibility -> view.setVisibility(toViewVisibility.apply(visibility)),
                posId,
                pipelineMaker);
    }

    @SuppressWarnings("RestrictTo")
    static AnimationSet getEnterAnimations(
            @NonNull EnterTransition enterTransition, @NonNull View view) {
        AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false);
        if (enterTransition.hasFadeIn()) {
            FadeInTransition fadeIn = enterTransition.getFadeIn();
            AlphaAnimation alphaAnimation =
                    new AlphaAnimation(fadeIn.getInitialAlpha(), FADE_IN_TARGET_ALPHA);

            // If it doesn't exist, this will be default object.
            AnimationSpec spec = fadeIn.getAnimationSpec();

            AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec);
            animations.addAnimation(alphaAnimation);
        }

        if (enterTransition.hasSlideIn()) {
            SlideInTransition slideIn = enterTransition.getSlideIn();

            // If it doesn't exist, this will be default object.
            AnimationSpec spec = slideIn.getAnimationSpec();

            float fromXDelta = 0;
            float toXDelta = 0;
            float fromYDelta = 0;
            float toYDelta = 0;

            switch (slideIn.getDirectionValue()) {
                case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE:
                    // Do the same as for horizontal as that is default.
                case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE:
                case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE:
                    fromXDelta = getInitialOffsetOrDefaultX(slideIn, view);
                    break;
                case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE:
                case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE:
                    fromYDelta = getInitialOffsetOrDefaultY(slideIn, view);
                    break;
            }

            TranslateAnimation translateAnimation =
                    new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta);
            AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec);
            animations.addAnimation(translateAnimation);
        }
        return animations;
    }

    @SuppressWarnings("RestrictTo")
    static AnimationSet getExitAnimations(
            @NonNull ExitTransition exitTransition, @NonNull View view) {
        AnimationSet animations = new AnimationSet(/* shareInterpolator= */ false);
        if (exitTransition.hasFadeOut()) {
            FadeOutTransition fadeOut = exitTransition.getFadeOut();
            AlphaAnimation alphaAnimation =
                    new AlphaAnimation(FADE_OUT_INITIAL_ALPHA, fadeOut.getTargetAlpha());

            // If it doesn't exist, this will be default object.
            AnimationSpec spec = fadeOut.getAnimationSpec();

            // Indefinite Exit animations aren't allowed.
            if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) {
                AnimationsHelper.applyAnimationSpecToAnimation(alphaAnimation, spec);
                animations.addAnimation(alphaAnimation);
            }
        }

        if (exitTransition.hasSlideOut()) {
            SlideOutTransition slideOut = exitTransition.getSlideOut();

            // If it doesn't exist, this will be default object.
            AnimationSpec spec = slideOut.getAnimationSpec();
            // Indefinite Exit animations aren't allowed.
            if (!spec.hasRepeatable() || spec.getRepeatable().getIterations() != 0) {
                float fromXDelta = 0;
                float toXDelta = 0;
                float fromYDelta = 0;
                float toYDelta = 0;

                switch (slideOut.getDirectionValue()) {
                    case SlideDirection.SLIDE_DIRECTION_UNDEFINED_VALUE:
                        // Do the same as for horizontal as that is default.
                    case SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE:
                    case SlideDirection.SLIDE_DIRECTION_RIGHT_TO_LEFT_VALUE:
                        toXDelta = getTargetOffsetOrDefaultX(slideOut, view);
                        break;
                    case SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE:
                    case SlideDirection.SLIDE_DIRECTION_BOTTOM_TO_TOP_VALUE:
                        toYDelta = getTargetOffsetOrDefaultY(slideOut, view);
                        break;
                }

                TranslateAnimation translateAnimation =
                        new TranslateAnimation(fromXDelta, toXDelta, fromYDelta, toYDelta);
                AnimationsHelper.applyAnimationSpecToAnimation(translateAnimation, spec);
                animations.addAnimation(translateAnimation);
            }
        }
        return animations;
    }

    /**
     * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which
     * * is sliding to the left or right parent edge, depending on the direction.
     */
    private static float getInitialOffsetOrDefaultX(
            @NonNull SlideInTransition slideIn, @NonNull View view) {
        int sign =
                slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE
                        ? -1
                        : 1;
        if (slideIn.hasInitialSlideBound()) {

            switch (slideIn.getInitialSlideBound().getInnerCase()) {
                case LINEAR_BOUND:
                    return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign;
                case PARENT_BOUND:
                    if (slideIn.getInitialSlideBound().getParentBound().getSnapTo()
                            == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
                        return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight())
                                * sign;
                    }
                    // fall through
                case INNER_NOT_SET:
                    break;
            }
        }
        return (sign == -1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign;
    }

    /**
     * Returns offset from SlideInTransition if it's set. Otherwise, returns the default value which
     * * is sliding to the left or right parent edge, depending on the direction.
     */
    private static float getInitialOffsetOrDefaultY(
            @NonNull SlideInTransition slideIn, @NonNull View view) {
        int sign =
                slideIn.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE
                        ? -1
                        : 1;
        if (slideIn.hasInitialSlideBound()) {

            switch (slideIn.getInitialSlideBound().getInnerCase()) {
                case LINEAR_BOUND:
                    return slideIn.getInitialSlideBound().getLinearBound().getOffsetDp() * sign;
                case PARENT_BOUND:
                    if (slideIn.getInitialSlideBound().getParentBound().getSnapTo()
                            == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
                        return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom())
                                * sign;
                    }
                    // fall through
                case INNER_NOT_SET:
                    break;
            }
        }
        return (sign == -1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign;
    }

    /**
     * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value
     * which is sliding to the left or right parent edge, depending on the direction.
     */
    private static float getTargetOffsetOrDefaultX(
            @NonNull SlideOutTransition slideOut, @NonNull View view) {
        int sign =
                slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_LEFT_TO_RIGHT_VALUE
                        ? 1
                        : -1;
        if (slideOut.hasTargetSlideBound()) {

            switch (slideOut.getTargetSlideBound().getInnerCase()) {
                case LINEAR_BOUND:
                    return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign;
                case PARENT_BOUND:
                    if (slideOut.getTargetSlideBound().getParentBound().getSnapTo()
                            == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
                        return (sign == -1 ? (view.getLeft() + view.getWidth()) : view.getRight())
                                * sign;
                    }
                    // fall through
                case INNER_NOT_SET:
                    break;
            }
        }
        return (sign == 1 ? view.getLeft() : (view.getRight() - view.getWidth())) * sign;
    }

    /**
     * Returns offset from SlideOutTransition if it's set. Otherwise, returns the default value
     * which is sliding to the top or bottom parent edge, depending on the direction.
     */
    private static float getTargetOffsetOrDefaultY(
            @NonNull SlideOutTransition slideOut, @NonNull View view) {
        int sign =
                slideOut.getDirectionValue() == SlideDirection.SLIDE_DIRECTION_TOP_TO_BOTTOM_VALUE
                        ? 1
                        : -1;
        if (slideOut.hasTargetSlideBound()) {

            switch (slideOut.getTargetSlideBound().getInnerCase()) {
                case LINEAR_BOUND:
                    return slideOut.getTargetSlideBound().getLinearBound().getOffsetDp() * sign;
                case PARENT_BOUND:
                    if (slideOut.getTargetSlideBound().getParentBound().getSnapTo()
                            == SlideParentSnapOption.SLIDE_PARENT_SNAP_TO_OUTSIDE) {
                        return (sign == -1 ? (view.getTop() + view.getHeight()) : view.getBottom())
                                * sign;
                    }
                    // fall through
                case INNER_NOT_SET:
                    break;
            }
        }
        return (sign == 1 ? view.getTop() : (view.getBottom() - view.getHeight())) * sign;
    }

    // This is a little nasty; ArcLayout.Widget is just an interface, so we have no guarantee that
    // the instance also extends View (as it should). Instead, just take a View in and rename this,
    // and check that it's an ArcLayout.Widget internally.
    private View applyModifiersToArcLayoutView(
            View view,
            ArcModifiers modifiers,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (!(view instanceof ArcLayout.Widget)) {
            Log.e(
                    TAG,
                    "applyModifiersToArcLayoutView should only be called with an ArcLayout.Widget");
            return view;
        }

        if (modifiers.hasClickable()) {

            // set the extendTouchTarget as false to disable the clickable area extension for
            // meeting the required minimum clickable area
            applyClickable(
                    view,
                    /* wrapper= */ null,
                    modifiers.getClickable(),
                    /* extendTouchTarget= */ false);
        }

        if (modifiers.hasSemantics()) {
            applySemantics(view, modifiers.getSemantics(), posId, pipelineMaker);
        }

        if (modifiers.hasOpacity()) {
            handleProp(modifiers.getOpacity(), view::setAlpha, posId, pipelineMaker);
        }

        return view;
    }

    private static int textAlignToAndroidGravity(TextAlignment alignment) {
        switch (alignment) {
            case TEXT_ALIGN_START:
                return Gravity.START;
            case TEXT_ALIGN_CENTER:
                return Gravity.CENTER_HORIZONTAL;
            case TEXT_ALIGN_END:
                return Gravity.END;
            case TEXT_ALIGN_UNDEFINED:
            case UNRECOGNIZED:
                return TEXT_ALIGN_DEFAULT;
        }

        return TEXT_ALIGN_DEFAULT;
    }

    @Nullable
    private static TruncateAt textTruncationToEllipsize(TextOverflow overflowValue) {
        switch (overflowValue) {
            case TEXT_OVERFLOW_TRUNCATE:
                // A null TruncateAt disables adding an ellipsis.
                return null;
            case TEXT_OVERFLOW_ELLIPSIZE_END:
            case TEXT_OVERFLOW_ELLIPSIZE:
                return TruncateAt.END;
            case TEXT_OVERFLOW_MARQUEE:
                return TruncateAt.MARQUEE;
            case TEXT_OVERFLOW_UNDEFINED:
            case UNRECOGNIZED:
                return TEXT_OVERFLOW_DEFAULT;
        }

        return TEXT_OVERFLOW_DEFAULT;
    }

    @ArcLayout.AnchorType
    private static int anchorTypeToAnchorPos(ArcAnchorType type) {
        switch (type) {
            case ARC_ANCHOR_START:
                return ArcLayout.ANCHOR_START;
            case ARC_ANCHOR_CENTER:
                return ArcLayout.ANCHOR_CENTER;
            case ARC_ANCHOR_END:
                return ArcLayout.ANCHOR_END;
            case ARC_ANCHOR_UNDEFINED:
            case UNRECOGNIZED:
                return ARC_ANCHOR_DEFAULT;
        }

        return ARC_ANCHOR_DEFAULT;
    }

    @SizedArcContainer.LayoutParams.AngularAlignment
    private static int angularAlignmentProtoToAngularAlignment(AngularAlignment angularAlignment) {
        switch (angularAlignment) {
            case ANGULAR_ALIGNMENT_START:
                return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_START;
            case ANGULAR_ALIGNMENT_CENTER:
                return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_CENTER;
            case ANGULAR_ALIGNMENT_END:
                return SizedArcContainer.LayoutParams.ANGULAR_ALIGNMENT_END;
            case ANGULAR_ALIGNMENT_UNDEFINED:
            case UNRECOGNIZED:
                return ANGULAR_ALIGNMENT_DEFAULT;
        }

        return ANGULAR_ALIGNMENT_DEFAULT;
    }

    private int dimensionToPx(ContainerDimension containerDimension) {
        switch (containerDimension.getInnerCase()) {
            case LINEAR_DIMENSION:
                return safeDpToPx(containerDimension.getLinearDimension());
            case EXPANDED_DIMENSION:
                return LayoutParams.MATCH_PARENT;
            case WRAPPED_DIMENSION:
                return LayoutParams.WRAP_CONTENT;
            case INNER_NOT_SET:
                return dimensionToPx(CONTAINER_DIMENSION_DEFAULT);
        }

        return dimensionToPx(CONTAINER_DIMENSION_DEFAULT);
    }

    private static int extractTextColorArgb(FontStyle fontStyle) {
        if (fontStyle.hasColor()) {
            return fontStyle.getColor().getArgb();
        } else {
            return TEXT_COLOR_DEFAULT;
        }
    }

    /**
     * Returns an Android {@link Intent} that can perform the action defined in the given layout
     * {@link LaunchAction}.
     */
    @Nullable
    public static Intent buildLaunchActionIntent(
            @NonNull LaunchAction launchAction,
            @NonNull String clickableId,
            @NonNull String clickableIdExtra) {
        if (launchAction.hasAndroidActivity()) {
            AndroidActivity activity = launchAction.getAndroidActivity();
            Intent i =
                    new Intent().setClassName(activity.getPackageName(), activity.getClassName());
            i.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK);

            if (!clickableId.isEmpty() && !clickableIdExtra.isEmpty()) {
                i.putExtra(clickableIdExtra, clickableId);
            }

            for (Map.Entry<String, AndroidExtra> entry : activity.getKeyToExtraMap().entrySet()) {
                if (entry.getValue().hasStringVal()) {
                    i.putExtra(entry.getKey(), entry.getValue().getStringVal().getValue());
                } else if (entry.getValue().hasIntVal()) {
                    i.putExtra(entry.getKey(), entry.getValue().getIntVal().getValue());
                } else if (entry.getValue().hasLongVal()) {
                    i.putExtra(entry.getKey(), entry.getValue().getLongVal().getValue());
                } else if (entry.getValue().hasDoubleVal()) {
                    i.putExtra(entry.getKey(), entry.getValue().getDoubleVal().getValue());
                } else if (entry.getValue().hasBooleanVal()) {
                    i.putExtra(entry.getKey(), entry.getValue().getBooleanVal().getValue());
                }
            }

            return i;
        }

        return null;
    }

    static State buildState(LoadAction loadAction, String clickableId) {
        // Get the state specified by the provider and add the last clicked clickable's ID to it.
        return loadAction.getRequestState().toBuilder().setLastClickableId(clickableId).build();
    }

    @Nullable
    private InflatedView inflateColumn(
            ParentViewWrapper parentViewWrapper,
            Column column,
            String columnPosId,
            boolean includeChildren,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        ContainerDimension width =
                column.hasWidth() ? column.getWidth() : CONTAINER_DIMENSION_DEFAULT;
        ContainerDimension height =
                column.hasHeight() ? column.getHeight() : CONTAINER_DIMENSION_DEFAULT;

        if (!canMeasureContainer(width, height, column.getContentsList())) {
            Log.w(TAG, "Column set to wrap but contents are unmeasurable. Ignoring.");
            return null;
        }

        LinearLayout linearLayout = new LinearLayout(mUiContext);
        linearLayout.setOrientation(LinearLayout.VERTICAL);

        LayoutParams layoutParams = generateDefaultLayoutParams();

        linearLayout.setGravity(
                horizontalAlignmentToGravity(column.getHorizontalAlignment().getValue()));

        layoutParams =
                updateLayoutParams(
                        parentViewWrapper.getParentProperties(), layoutParams, width, height);
        resolveMinimumDimensions(linearLayout, width, height);

        View wrappedView =
                applyModifiers(
                        linearLayout,
                        /* wrapper= */ null,
                        column.getModifiers(),
                        columnPosId,
                        pipelineMaker);

        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        if (includeChildren) {
            inflateChildElements(
                    linearLayout,
                    layoutParams,
                    NO_OP_PENDING_LAYOUT_PARAMS,
                    column.getContentsList(),
                    columnPosId,
                    layoutInfoBuilder,
                    pipelineMaker);
            layoutInfoBuilder.removeSubtree(columnPosId);
        }

        int numMissingChildren = includeChildren ? 0 : column.getContentsCount();
        return new InflatedView(
                wrappedView,
                parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
                NO_OP_PENDING_LAYOUT_PARAMS,
                numMissingChildren);
    }

    @Nullable
    private InflatedView inflateRow(
            ParentViewWrapper parentViewWrapper,
            Row row,
            String rowPosId,
            boolean includeChildren,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        ContainerDimension width = row.hasWidth() ? row.getWidth() : CONTAINER_DIMENSION_DEFAULT;
        ContainerDimension height = row.hasHeight() ? row.getHeight() : CONTAINER_DIMENSION_DEFAULT;

        if (!canMeasureContainer(width, height, row.getContentsList())) {
            Log.w(TAG, "Row set to wrap but contents are unmeasurable. Ignoring.");
            return null;
        }

        LinearLayout linearLayout = new LinearLayout(mUiContext);
        linearLayout.setOrientation(LinearLayout.HORIZONTAL);

        LayoutParams layoutParams = generateDefaultLayoutParams();

        linearLayout.setGravity(verticalAlignmentToGravity(row.getVerticalAlignment().getValue()));

        layoutParams =
                updateLayoutParams(
                        parentViewWrapper.getParentProperties(), layoutParams, width, height);
        resolveMinimumDimensions(linearLayout, width, height);

        View wrappedView =
                applyModifiers(
                        linearLayout,
                        /* wrapper= */ null,
                        row.getModifiers(),
                        rowPosId,
                        pipelineMaker);

        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        if (includeChildren) {
            inflateChildElements(
                    linearLayout,
                    layoutParams,
                    NO_OP_PENDING_LAYOUT_PARAMS,
                    row.getContentsList(),
                    rowPosId,
                    layoutInfoBuilder,
                    pipelineMaker);
            layoutInfoBuilder.removeSubtree(rowPosId);
        }

        int numMissingChildren = includeChildren ? 0 : row.getContentsCount();
        return new InflatedView(
                wrappedView,
                parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
                NO_OP_PENDING_LAYOUT_PARAMS,
                numMissingChildren);
    }

    // dereference of possibly-null reference lp
    @SuppressWarnings("nullness:dereference.of.nullable")
    @Nullable
    private InflatedView inflateBox(
            ParentViewWrapper parentViewWrapper,
            Box box,
            String boxPosId,
            boolean includeChildren,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        ContainerDimension width = box.hasWidth() ? box.getWidth() : CONTAINER_DIMENSION_DEFAULT;
        ContainerDimension height = box.hasHeight() ? box.getHeight() : CONTAINER_DIMENSION_DEFAULT;

        if (!canMeasureContainer(width, height, box.getContentsList())) {
            Log.w(TAG, "Box set to wrap but contents are unmeasurable. Ignoring.");
            return null;
        }

        FrameLayout frame = new FrameLayout(mUiContext);

        LayoutParams layoutParams = generateDefaultLayoutParams();

        layoutParams =
                updateLayoutParams(
                        parentViewWrapper.getParentProperties(), layoutParams, width, height);
        resolveMinimumDimensions(frame, width, height);

        int gravity =
                getFrameLayoutGravity(
                        box.getHorizontalAlignment().getValue(),
                        box.getVerticalAlignment().getValue());
        PendingFrameLayoutParams childLayoutParams = new PendingFrameLayoutParams(gravity);

        View wrappedView =
                applyModifiers(
                        frame, /* wrapper= */ null, box.getModifiers(), boxPosId, pipelineMaker);

        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        if (includeChildren) {
            inflateChildElements(
                    frame,
                    layoutParams,
                    childLayoutParams,
                    box.getContentsList(),
                    boxPosId,
                    layoutInfoBuilder,
                    pipelineMaker);
            layoutInfoBuilder.removeSubtree(boxPosId);
        }

        // We can't set layout gravity to a FrameLayout ahead of time (and foregroundGravity only
        // sets the gravity of the foreground Drawable). Go and apply gravity to the child.
        try {
            applyGravityToFrameLayoutChildren(frame, gravity);
        } catch (IllegalStateException ex) {
            Log.e(TAG, "Error applying Gravity to FrameLayout children.", ex);
        }

        // HACK: FrameLayout has a bug in it. If we add one WRAP_CONTENT child, and one MATCH_PARENT
        // child, the expected behaviour is that the FrameLayout sizes itself to fit the
        // WRAP_CONTENT child (e.g. a TextView), then the MATCH_PARENT child is forced to the same
        // size as the outer FrameLayout (and hence, the size of the TextView, after accounting for
        // padding etc). Because of a bug though, this doesn't happen; instead, the MATCH_PARENT
        // child will just keep its intrinsic size. This is because FrameLayout only forces
        // MATCH_PARENT children to a given size if there are _more than one_ of them (see the
        // bottom of FrameLayout#onMeasure).
        //
        // To work around this (without copying the whole of FrameLayout just to change a "1" to
        // "0"),
        // we add a Space element in if there is one MATCH_PARENT child. This has a tiny cost to the
        // measure pass, and negligible cost to layout/draw (since it doesn't take part in those
        // passes).
        int numMatchParentChildren = 0;
        for (int i = 0; i < frame.getChildCount(); i++) {
            LayoutParams lp = frame.getChildAt(i).getLayoutParams();
            if (lp.width == LayoutParams.MATCH_PARENT || lp.height == LayoutParams.MATCH_PARENT) {
                numMatchParentChildren++;
            }
        }

        if (numMatchParentChildren == 1) {
            Space hackSpace = new Space(mUiContext);
            LayoutParams hackSpaceLp =
                    new LayoutParams(LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
            frame.addView(hackSpace, hackSpaceLp);
        }

        int numMissingChildren = includeChildren ? 0 : box.getContentsCount();
        return new InflatedView(
                wrappedView,
                parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
                childLayoutParams,
                numMissingChildren);
    }

    @Nullable
    private InflatedView inflateSpacer(
            ParentViewWrapper parentViewWrapper,
            Spacer spacer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        LayoutParams layoutParams = generateDefaultLayoutParams();
        layoutParams =
                updateLayoutParams(
                        parentViewWrapper.getParentProperties(),
                        layoutParams,
                        // This doesn't copy layout constraint for dynamic fields. That is fine,
                        // because that will be later applied to the wrapper, and this layoutParams
                        // would have dimension reset and put into the pipeline.
                        spacerDimensionToContainerDimension(spacer.getWidth()),
                        spacerDimensionToContainerDimension(spacer.getHeight()));

        // Initialize the size wrapper here, if needed. This simplifies the logic below when
        // creating the actual Spacer and adding it to its parent...
        FrameLayout sizeWrapper = null;
        @Nullable Float widthForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getWidth());
        @Nullable Float heightForLayoutDp = resolveSizeForLayoutIfNeeded(spacer.getHeight());

        // Handling dynamic width/height for the spacer.
        if (widthForLayoutDp != null || heightForLayoutDp != null) {
            sizeWrapper = new FrameLayout(mUiContext);
            LayoutParams spaceWrapperLayoutParams = generateDefaultLayoutParams();
            spaceWrapperLayoutParams =
                    updateLayoutParams(
                            parentViewWrapper.getParentProperties(),
                            spaceWrapperLayoutParams,
                            spacerDimensionToContainerDimension(spacer.getWidth()),
                            spacerDimensionToContainerDimension(spacer.getHeight()));

            if (widthForLayoutDp != null) {
                if (widthForLayoutDp <= 0f) {
                    Log.w(
                            TAG,
                            "Spacer width's value_for_layout is not a positive value. Element won't"
                                    + " be visible.");
                }
                spaceWrapperLayoutParams.width = safeDpToPx(widthForLayoutDp);
            }

            if (heightForLayoutDp != null) {
                if (heightForLayoutDp <= 0f) {
                    Log.w(
                            TAG,
                            "Spacer height's value_for_layout is not a positive value. Element"
                                    + " won't be visible.");
                }
                spaceWrapperLayoutParams.height = safeDpToPx(heightForLayoutDp);
            }

            int gravity =
                    (spacer.getWidth().hasLinearDimension()
                                    ? horizontalAlignmentToGravity(
                                            spacer.getWidth()
                                                    .getLinearDimension()
                                                    .getHorizontalAlignmentForLayout())
                                    : UNSET_MASK)
                            | (spacer.getHeight().hasLinearDimension()
                                    ? verticalAlignmentToGravity(
                                            spacer.getHeight()
                                                    .getLinearDimension()
                                                    .getVerticalAlignmentForLayout())
                                    : UNSET_MASK);

            // This layoutParams will override what we initially had and will be used for Spacer
            // itself. This means that the wrapper's layout params should follow the rules for
            // expand, and this one should just match the parents size when dimension is set to
            // expand. When dimension is dynamic, the value will be assigned during the
            // evaluation, so currently we will just copy over the value from constraints.
            FrameLayout.LayoutParams frameLayoutLayoutParams =
                    new FrameLayout.LayoutParams(layoutParams);
            frameLayoutLayoutParams.gravity = gravity;
            if (spacer.getWidth().hasExpandedDimension()) {
                frameLayoutLayoutParams.width = LayoutParams.MATCH_PARENT;
            }
            if (spacer.getHeight().hasExpandedDimension()) {
                frameLayoutLayoutParams.height = LayoutParams.MATCH_PARENT;
            }
            layoutParams = frameLayoutLayoutParams;

            parentViewWrapper.maybeAddView(sizeWrapper, spaceWrapperLayoutParams);

            parentViewWrapper = new ParentViewWrapper(sizeWrapper, spaceWrapperLayoutParams);
        }

        // Modifiers cannot be applied to android's Space, so use a plain View if this Spacer has
        // modifiers.
        View view;

        // Init the layout params to 0 in case of linear dimension (so we don't get strange
        // behaviour before the first data pipeline update).
        if (spacer.getWidth().hasLinearDimension()) {
            layoutParams.width = 0;
        }
        if (spacer.getHeight().hasLinearDimension()) {
            layoutParams.height = 0;
        }

        if (spacer.hasModifiers()) {
            view =
                    applyModifiers(
                            new View(mUiContext),
                            sizeWrapper,
                            spacer.getModifiers(),
                            posId,
                            pipelineMaker);

            // LayoutParams have been updated above to accommodate from expand option.
            // The View needs to be added before any of the *Prop messages are wired up.
            // View#getLayoutParams will return null if the View has not been added to a container
            // yet
            // (since the LayoutParams are technically managed by the parent).
            parentViewWrapper.maybeAddView(view, layoutParams);

            if (spacer.getWidth().hasLinearDimension()) {
                handleProp(
                        spacer.getWidth().getLinearDimension(),
                        widthDp -> updateLayoutWidthParam(view, widthDp),
                        posId,
                        pipelineMaker);
            }

            if (spacer.getHeight().hasLinearDimension()) {
                handleProp(
                        spacer.getHeight().getLinearDimension(),
                        heightDp -> updateLayoutHeightParam(view, heightDp),
                        posId,
                        pipelineMaker);
            }
        } else {
            view = new Space(mUiContext);
            parentViewWrapper.maybeAddView(view, layoutParams);
            if (spacer.getWidth().hasLinearDimension()) {
                handleProp(
                        spacer.getWidth().getLinearDimension(),
                        widthDp -> updateLayoutWidthParam(view, widthDp),
                        posId,
                        pipelineMaker);
            }
            if (spacer.getHeight().hasLinearDimension()) {
                handleProp(
                        spacer.getHeight().getLinearDimension(),
                        heightDp -> updateLayoutHeightParam(view, heightDp),
                        posId,
                        pipelineMaker);
            }
        }

        if (sizeWrapper != null) {
            return new InflatedView(
                    sizeWrapper,
                    parentViewWrapper
                            .getParentProperties()
                            .applyPendingChildLayoutParams(layoutParams));
        } else {
            return new InflatedView(
                    view,
                    parentViewWrapper
                            .getParentProperties()
                            .applyPendingChildLayoutParams(layoutParams));
        }
    }

    private void updateLayoutWidthParam(@NonNull View view, float widthDp) {
        scheduleLayoutParamsUpdate(
                view,
                () -> {
                    checkNotNull(view.getLayoutParams()).width = safeDpToPx(widthDp);
                    view.requestLayout();
                });
    }

    private void updateLayoutHeightParam(@NonNull View view, float heightDp) {
        scheduleLayoutParamsUpdate(
                view,
                () -> {
                    checkNotNull(view.getLayoutParams()).height = safeDpToPx(heightDp);
                    view.requestLayout();
                });
    }

    private void scheduleLayoutParamsUpdate(@NonNull View view, Runnable layoutParamsUpdater) {
        if (view.getLayoutParams() != null) {
            layoutParamsUpdater.run();
            return;
        }

        // View#getLayoutParams() returns null if this view is not attached to a parent ViewGroup.
        // And once the view is attached to a parent ViewGroup, it guarantees a non-null return
        // value. Thus, we use the listener to do the update of the layout param the moment that the
        // view is attached to window.
        view.addOnAttachStateChangeListener(
                new View.OnAttachStateChangeListener() {

                    @Override
                    public void onViewAttachedToWindow(@NonNull View v) {
                        layoutParamsUpdater.run();
                        v.removeOnAttachStateChangeListener(this);
                    }

                    @Override
                    public void onViewDetachedFromWindow(@NonNull View v) {}
                });
    }

    @Nullable
    private InflatedView inflateArcSpacer(
            ParentViewWrapper parentViewWrapper,
            ArcSpacer spacer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        float lengthDegrees = 0;
        int thicknessPx = safeDpToPx(spacer.getThickness());
        WearCurvedSpacer space = new WearCurvedSpacer(mUiContext);
        ArcLayout.LayoutParams layoutParams =
                new ArcLayout.LayoutParams(generateDefaultLayoutParams());

        if (spacer.hasAngularLength()) {
            final ArcSpacerLength angularLength = spacer.getAngularLength();
            switch (angularLength.getInnerCase()) {
                case DEGREES:
                    lengthDegrees = max(0, angularLength.getDegrees().getValue());
                    break;

                case EXPANDED_ANGULAR_DIMENSION:
                    {
                        float weight =
                                angularLength
                                        .getExpandedAngularDimension()
                                        .getLayoutWeight()
                                        .getValue();
                        if (weight == 0 && thicknessPx == 0) {
                            return null;
                        }
                        layoutParams.setWeight(weight);

                        space.setThickness(thicknessPx);

                        View wrappedView =
                                applyModifiersToArcLayoutView(
                                        space, spacer.getModifiers(), posId, pipelineMaker);
                        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

                        return new InflatedView(
                                wrappedView,
                                parentViewWrapper
                                        .getParentProperties()
                                        .applyPendingChildLayoutParams(layoutParams));
                    }

                case INNER_NOT_SET:
                    break;
            }
        } else {
            lengthDegrees = max(0, spacer.getLength().getValue());
        }

        if (lengthDegrees == 0 && thicknessPx == 0) {
            return null;
        }
        space.setSweepAngleDegrees(lengthDegrees);
        space.setThickness(thicknessPx);

        View wrappedView =
                applyModifiersToArcLayoutView(space, spacer.getModifiers(), posId, pipelineMaker);
        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        return new InflatedView(
                wrappedView,
                parentViewWrapper
                        .getParentProperties()
                        .applyPendingChildLayoutParams(layoutParams));
    }

    private void applyTextOverflow(
            TextView textView, TextOverflowProp overflow, MarqueeParameters marqueeParameters) {
        TextOverflow overflowValue = overflow.getValue();
        if (!mAnimationEnabled && overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE) {
            overflowValue = TextOverflow.TEXT_OVERFLOW_UNDEFINED;
        }

        textView.setEllipsize(textTruncationToEllipsize(overflowValue));
        if (overflowValue == TextOverflow.TEXT_OVERFLOW_MARQUEE && textView.getMaxLines() == 1) {
            int marqueeIterations =
                    marqueeParameters.hasIterations()
                            ? marqueeParameters.getIterations()
                            : -1; // Defaults to repeat indefinitely (-1).
            textView.setMarqueeRepeatLimit(marqueeIterations);
            textView.setSelected(true);
            textView.setSingleLine();
            textView.setHorizontalFadingEdgeEnabled(true);
        }
    }

    private InflatedView inflateText(
            ParentViewWrapper parentViewWrapper,
            Text text,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        TextView textView = newThemedTextView();

        LayoutParams layoutParams = generateDefaultLayoutParams();

        handleProp(
                text.getText(),
                t -> {
                    // Underlines are applied using a Spannable here, rather than setting paint bits
                    // (or
                    // using Paint#setTextUnderline). When multiple fonts are mixed on the same line
                    // (especially when mixing anything with NotoSans-CJK), multiple underlines can
                    // appear. Using UnderlineSpan instead though causes the correct behaviour to
                    // happen
                    // (only a
                    // single underline).
                    SpannableStringBuilder ssb = new SpannableStringBuilder();
                    ssb.append(t);

                    if (text.getFontStyle().getUnderline().getValue()) {
                        ssb.setSpan(new UnderlineSpan(), 0, ssb.length(), Spanned.SPAN_MARK_MARK);
                    }

                    textView.setText(ssb);
                },
                posId,
                pipelineMaker);

        textView.setGravity(textAlignToAndroidGravity(text.getMultilineAlignment().getValue()));

        @Nullable String valueForLayout = resolveValueForLayoutIfNeeded(text.getText());

        // Use valueForLayout as a proxy for "has a dynamic size". If there's a dynamic binding for
        // the text element, then it can only have a single line of text.
        if (text.hasMaxLines() && valueForLayout == null) {
            textView.setMaxLines(max(TEXT_MIN_LINES, text.getMaxLines().getValue()));
        } else {
            textView.setMaxLines(TEXT_MAX_LINES_DEFAULT);
        }

        TextOverflowProp overflow = text.getOverflow();
        applyTextOverflow(textView, overflow, text.getMarqueeParameters());

        if (overflow.getValue() == TextOverflow.TEXT_OVERFLOW_ELLIPSIZE
                && !text.getText().hasDynamicValue()
                // There's no need for any optimizations if max lines is already 1.
                && textView.getMaxLines() != 1) {
            OneOffPreDrawListener.add(textView, () -> adjustMaxLinesForEllipsize(textView));
        }

        // Text auto size is not supported for dynamic text.
        boolean isAutoSizeAllowed = !(text.hasText() && text.getText().hasDynamicValue());
        // Setting colours **must** go after setting the Text Appearance, otherwise it will get
        // immediately overridden.
        if (text.hasFontStyle()) {
            applyFontStyle(text.getFontStyle(), textView, posId, pipelineMaker, isAutoSizeAllowed);
        } else {
            applyFontStyle(
                    FontStyle.getDefaultInstance(),
                    textView,
                    posId,
                    pipelineMaker,
                    isAutoSizeAllowed);
        }

        // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also
        // have excluded font padding. Here, it's being ignored, and default value (excluded font
        // padding) is used.
        applyExcludeFontPadding(textView);

        if (text.hasLineHeight()) {
            float lineHeightPx = toPx(text.getLineHeight());
            final float fontHeightPx = textView.getPaint().getFontSpacing();
            if (lineHeightPx != fontHeightPx) {
                textView.setLineSpacing(lineHeightPx - fontHeightPx, 1f);
            }
        }

        // We don't want the text to be screen-reader focusable, unless wrapped in a Spannable
        // modifier. This prevents automatically reading out partial text (e.g. text in a row) etc.
        //
        // This **must** be done before applying modifiers; applying a Semantics modifier will set
        // importantForAccessibility, so we don't want to override it after applying modifiers.
        textView.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_NO);

        if (valueForLayout != null) {
            if (valueForLayout.isEmpty()) {
                Log.w(TAG, "Text's value_for_layout is empty. Element won't be visible.");
            }

            // Now create a "container" element, with that size, to hold the text.
            FrameLayout sizeChangingTextWrapper = new FrameLayout(mUiContext);
            LayoutParams sizeChangingTextWrapperLayoutParams = generateDefaultLayoutParams();
            // Use the actual TextView to measure the text width.
            sizeChangingTextWrapperLayoutParams.width =
                    (int) textView.getPaint().measureText(valueForLayout);
            sizeChangingTextWrapperLayoutParams.height = LayoutParams.WRAP_CONTENT;
            View wrappedView =
                    applyModifiers(
                            textView,
                            sizeChangingTextWrapper,
                            text.getModifiers(),
                            posId,
                            pipelineMaker);

            // Set horizontal gravity on the wrapper to reflect alignment.
            int gravity = textAlignToAndroidGravity(text.getText().getTextAlignmentForLayout());
            FrameLayout.LayoutParams frameLayoutLayoutParams =
                    new FrameLayout.LayoutParams(layoutParams);
            frameLayoutLayoutParams.gravity = gravity;
            layoutParams = frameLayoutLayoutParams;

            sizeChangingTextWrapper.addView(wrappedView, layoutParams);
            parentViewWrapper.maybeAddView(
                    sizeChangingTextWrapper, sizeChangingTextWrapperLayoutParams);
            return new InflatedView(
                    sizeChangingTextWrapper,
                    parentViewWrapper
                            .getParentProperties()
                            .applyPendingChildLayoutParams(sizeChangingTextWrapperLayoutParams));
        } else {
            View wrappedView =
                    applyModifiers(
                            textView,
                            /* wrapper= */ null,
                            text.getModifiers(),
                            posId,
                            pipelineMaker);
            parentViewWrapper.maybeAddView(wrappedView, layoutParams);
            return new InflatedView(
                    wrappedView,
                    parentViewWrapper
                            .getParentProperties()
                            .applyPendingChildLayoutParams(layoutParams));
        }
    }

    /**
     * Sorts out what maxLines should be if the text could possibly be truncated before maxLines is
     * reached.
     *
     * <p>Should be only called for the {@link TextOverflow#TEXT_OVERFLOW_ELLIPSIZE} option which
     * ellipsizes the text even before the last line, if there's no space for all lines. This is
     * different than what TEXT_OVERFLOW_ELLIPSIZE_END does, as that option just ellipsizes the last
     * line of text.
     */
    private static boolean adjustMaxLinesForEllipsize(@NonNull TextView textView) {
        ViewParent maybeParent = textView.getParent();
        if (!(maybeParent instanceof View)) {
            Log.d(
                    TAG,
                    "Couldn't adjust max lines for ellipsizing as there's no View/ViewGroup"
                            + " parent.");
            return true;
        }

        View parent = (View) maybeParent;
        int availableHeight = parent.getHeight();
        int oneLineHeight = textView.getLineHeight();
        // This is what was set in proto, we shouldn't exceed it.
        int maxMaxLines = textView.getMaxLines();
        // Avoid having maxLines as 0 in case the space is really tight.
        int availableLines = max(availableHeight / oneLineHeight, 1);

        // Update only if changed.
        if (availableLines < maxMaxLines) {
            textView.setMaxLines(availableLines);
            // Cancel the current drawing pass.
            return false;
        }

        return true;
    }

    /**
     * Sets font padding to be excluded and applies correct padding to the TextView to avoid
     * clipping taller languages.
     */
    private void applyExcludeFontPadding(TextView textView) {
        textView.setIncludeFontPadding(false);

        // We need to update padding in the TextView to avoid clipping of taller languages.

        float ascent = textView.getPaint().getFontMetrics().ascent;
        float descent = textView.getPaint().getFontMetrics().descent;
        String text = textView.getText().toString();
        Rect bounds = new Rect();

        textView.getPaint().getTextBounds(text, 0, max(0, text.length() - 1), bounds);

        int topPadding = textView.getPaddingTop();
        int bottomPadding = textView.getPaddingBottom();

        if (ascent > bounds.top) {
            topPadding = (int) (ascent - bounds.top);
        }

        if (descent < bounds.bottom) {
            bottomPadding = (int) (bounds.bottom - descent);
        }

        textView.setPadding(
                textView.getPaddingLeft(), topPadding, textView.getPaddingRight(), bottomPadding);
    }

    private InflatedView inflateArcText(
            ParentViewWrapper parentViewWrapper,
            ArcText text,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        CurvedTextView textView = newThemedCurvedTextView();

        LayoutParams layoutParams = generateDefaultLayoutParams();
        layoutParams.width = LayoutParams.MATCH_PARENT;
        layoutParams.height = LayoutParams.MATCH_PARENT;

        textView.setText(text.getText().getValue());

        if (text.hasFontStyle()) {
            applyFontStyle(text.getFontStyle(), textView);
        } else if (mApplyFontVariantBodyAsDefault) {
            applyFontStyle(FontStyle.getDefaultInstance(), textView);
        }

        // Setting colours **must** go after setting the Text Appearance, otherwise it will get
        // immediately overridden.
        textView.setTextColor(extractTextColorArgb(text.getFontStyle()));

        if (text.hasArcDirection()) {
            switch (text.getArcDirection().getValue()) {
                case ARC_DIRECTION_NORMAL:
                    textView.setClockwise(!isRtlLayoutDirectionFromLocale());
                    break;
                case ARC_DIRECTION_COUNTER_CLOCKWISE:
                    textView.setClockwise(false);
                    break;
                case ARC_DIRECTION_CLOCKWISE:
                case UNRECOGNIZED:
                    textView.setClockwise(true);
                    break;
            }
        }

        View wrappedView =
                applyModifiersToArcLayoutView(textView, text.getModifiers(), posId, pipelineMaker);
        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        return new InflatedView(
                wrappedView,
                parentViewWrapper
                        .getParentProperties()
                        .applyPendingChildLayoutParams(layoutParams));
    }

    private static boolean isZeroLengthImageDimension(ImageDimension dimension) {
        return dimension.getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION
                && dimension.getLinearDimension().getValue() == 0;
    }

    private static ContainerDimension imageDimensionToContainerDimension(ImageDimension dimension) {
        switch (dimension.getInnerCase()) {
            case LINEAR_DIMENSION:
                return ContainerDimension.newBuilder()
                        .setLinearDimension(dimension.getLinearDimension())
                        .build();
            case EXPANDED_DIMENSION:
                return ContainerDimension.newBuilder()
                        .setExpandedDimension(ExpandedDimensionProp.getDefaultInstance())
                        .build();
            case PROPORTIONAL_DIMENSION:
                // A ratio size should be translated to a WRAP_CONTENT; the RatioViewWrapper will
                // deal with the sizing of that.
                return ContainerDimension.newBuilder()
                        .setWrappedDimension(WrappedDimensionProp.getDefaultInstance())
                        .build();
            case INNER_NOT_SET:
                break;
        }
        // Caller should have already checked for this.
        throw new IllegalArgumentException(
                "ImageDimension has an unknown dimension type: " + dimension.getInnerCase().name());
    }

    @SuppressWarnings("ExecutorTaskName")
    @Nullable
    private InflatedView inflateImage(
            ParentViewWrapper parentViewWrapper,
            Image image,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        String protoResId = image.getResourceId().getValue();

        // If either width or height isn't set, abort.
        if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET
                || image.getHeight().getInnerCase() == ImageDimension.InnerCase.INNER_NOT_SET) {
            Log.w(TAG, "One of width and height not set on image " + protoResId);
            return null;
        }

        // The image must occupy _some_ space.
        if (isZeroLengthImageDimension(image.getWidth())
                || isZeroLengthImageDimension(image.getHeight())) {
            Log.w(TAG, "One of width and height was zero on image " + protoResId);
            return null;
        }

        // Both dimensions can't be ratios.
        if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION
                && image.getHeight().getInnerCase()
                        == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
            Log.w(TAG, "Both width and height were proportional for image " + protoResId);
            return null;
        }

        // Pull the ratio for the RatioViewWrapper. Was either argument a proportional dimension?
        @Nullable Float ratio = RatioViewWrapper.UNDEFINED_ASPECT_RATIO;

        if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
            ratio = safeAspectRatioOrNull(image.getWidth().getProportionalDimension());
        }

        if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.PROPORTIONAL_DIMENSION) {
            ratio = safeAspectRatioOrNull(image.getHeight().getProportionalDimension());
        }

        if (ratio == null) {
            Log.w(TAG, "Invalid aspect ratio for image " + protoResId);
            return null;
        }

        ImageViewWithoutIntrinsicSizes imageView = new ImageViewWithoutIntrinsicSizes(mUiContext);

        if (image.hasContentScaleMode()) {
            imageView.setScaleType(
                    contentScaleModeToScaleType(image.getContentScaleMode().getValue()));
        }

        if (image.getWidth().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) {
            imageView.setMinimumWidth(safeDpToPx(image.getWidth().getLinearDimension()));
        }

        if (image.getHeight().getInnerCase() == ImageDimension.InnerCase.LINEAR_DIMENSION) {
            imageView.setMinimumHeight(safeDpToPx(image.getHeight().getLinearDimension()));
        }

        // We need to sort out the sizing of the widget now, so we can pass the correct params to
        // RatioViewWrapper. First, translate the ImageSize to a ContainerSize. A ratio size should
        // be translated to a WRAP_CONTENT; the RatioViewWrapper will deal with the sizing of that.
        LayoutParams ratioWrapperLayoutParams = generateDefaultLayoutParams();
        ratioWrapperLayoutParams =
                updateLayoutParams(
                        parentViewWrapper.getParentProperties(),
                        ratioWrapperLayoutParams,
                        imageDimensionToContainerDimension(image.getWidth()),
                        imageDimensionToContainerDimension(image.getHeight()));

        // Apply the modifiers to the ImageView, **not** the RatioViewWrapper.
        //
        // RatioViewWrapper doesn't do any custom drawing, it only exists to force dimensions during
        // the measure/layout passes, so it doesn't matter which element any border/background
        // modifiers get applied to. Applying modifiers to the ImageView is important for Semantics
        // though; screen readers try and pick up on the type of element being read, so in the case
        // of an image would read "image, <description>" (where the location of "image" can move
        // depending on user settings). If we apply the modifiers to RatioViewWrapper though, screen
        // readers will not realise that this is an image, and will read the incorrect description.
        RatioViewWrapper ratioViewWrapper = new RatioViewWrapper(mUiContext);
        ratioViewWrapper.setAspectRatio(ratio);
        View wrappedImageView =
                applyModifiers(
                        imageView, ratioViewWrapper, image.getModifiers(), posId, pipelineMaker);
        ratioViewWrapper.addView(wrappedImageView);

        parentViewWrapper.maybeAddView(ratioViewWrapper, ratioWrapperLayoutParams);

        ListenableFuture<Drawable> drawableFuture =
                mLayoutResourceResolvers.getDrawable(protoResId);
        Drawable immediatelySetDrawable = null;
        if (drawableFuture.isDone() && !drawableFuture.isCancelled()) {
            // If the future is done, immediately draw.
            immediatelySetDrawable = setImageDrawable(imageView, drawableFuture, protoResId);
        }

        if (immediatelySetDrawable != null && pipelineMaker.isPresent()) {
            if (immediatelySetDrawable instanceof AnimatedVectorDrawable) {
                AnimatedVectorDrawable avd = (AnimatedVectorDrawable) immediatelySetDrawable;
                try {
                    Trigger trigger = mLayoutResourceResolvers.getAnimationTrigger(protoResId);

                    if (trigger != null
                            && trigger.getInnerCase()
                                    == Trigger.InnerCase.ON_CONDITION_MET_TRIGGER) {
                        OnConditionMetTrigger conditionTrigger = trigger.getOnConditionMetTrigger();
                        pipelineMaker
                                .get()
                                .addResolvedAnimatedImageWithBoolTrigger(
                                        avd, trigger, posId, conditionTrigger.getCondition());
                    } else {
                        // Use default trigger if it's not set.
                        if (trigger == null
                                || trigger.getInnerCase() == Trigger.InnerCase.INNER_NOT_SET) {
                            trigger = DEFAULT_ANIMATION_TRIGGER;
                        }
                        pipelineMaker.get().addResolvedAnimatedImage(avd, trigger, posId);
                    }
                } catch (RuntimeException ex) {
                    Log.e(TAG, "Error setting up animation trigger", ex);
                }
            } else if (immediatelySetDrawable instanceof SeekableAnimatedVectorDrawable) {
                SeekableAnimatedVectorDrawable seekableAvd =
                        (SeekableAnimatedVectorDrawable) immediatelySetDrawable;
                try {
                    DynamicFloat progress = mLayoutResourceResolvers.getBoundProgress(protoResId);
                    if (progress != null) {
                        pipelineMaker
                                .get()
                                .addResolvedSeekableAnimatedImage(seekableAvd, progress, posId);
                    }
                } catch (IllegalArgumentException ex) {
                    Log.e(TAG, "Error setting up seekable animated image", ex);
                }
            }
        } else {
            // Is there a placeholder to use in the meantime?
            try {
                if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) {
                    if (setImageDrawable(
                                    imageView,
                                    mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(
                                            protoResId),
                                    protoResId)
                            == null) {
                        Log.w(TAG, "Failed to set the placeholder for " + protoResId);
                    }
                }
            } catch (ResourceAccessException | IllegalArgumentException ex) {
                Log.e(TAG, "Exception loading placeholder for resource " + protoResId, ex);
            }

            // Otherwise, handle the result on the UI thread.
            drawableFuture.addListener(
                    () -> setImageDrawable(imageView, drawableFuture, protoResId),
                    ContextCompat.getMainExecutor(mUiContext));
        }

        boolean canImageBeTinted = false;

        try {
            canImageBeTinted = mLayoutResourceResolvers.canImageBeTinted(protoResId);
        } catch (IllegalArgumentException ex) {
            Log.e(TAG, "Exception tinting image " + protoResId, ex);
        }

        if (image.getColorFilter().hasTint() && canImageBeTinted) {
            // Only allow tinting for Android images.f
            handleProp(
                    image.getColorFilter().getTint(),
                    tintColor -> {
                        ColorStateList tint = ColorStateList.valueOf(tintColor);
                        imageView.setImageTintList(tint);

                        // SRC_IN throws away the colours in the drawable that we're tinting.
                        // Effectively, the drawable being tinted is only a mask to apply the colour
                        // to.
                        imageView.setImageTintMode(Mode.SRC_IN);
                    },
                    posId,
                    pipelineMaker);
        }

        return new InflatedView(
                ratioViewWrapper,
                parentViewWrapper
                        .getParentProperties()
                        .applyPendingChildLayoutParams(ratioWrapperLayoutParams));
    }

    /**
     * Set drawable to the image view.
     *
     * @return Returns the drawable if it is successfully retrieved from the drawable future and set
     *     to the image view; otherwise returns null to indicate the failure of setting drawable.
     */
    @Nullable
    private Drawable setImageDrawable(
            ImageView imageView, Future<Drawable> drawableFuture, String protoResId) {
        try {
            return setImageDrawable(imageView, drawableFuture.get(), protoResId);
        } catch (ExecutionException | InterruptedException | CancellationException e) {
            Log.w(TAG, "Could not get drawable for image " + protoResId, e);
        }
        return null;
    }

    /**
     * Set drawable to the image view.
     *
     * @return Returns the drawable if it is successfully set to the image view; otherwise returns
     *     null to indicate the failure of setting drawable.
     */
    @Nullable
    private Drawable setImageDrawable(ImageView imageView, Drawable drawable, String protoResId) {
        if (drawable != null) {
            mInflaterStatsLogger.logDrawableUsage(drawable);
        }
        if (drawable instanceof BitmapDrawable
                && ((BitmapDrawable) drawable).getBitmap().getByteCount()
                        > DEFAULT_MAX_BITMAP_RAW_SIZE) {
            Log.w(TAG, "Ignoring image " + protoResId + " as it's too large.");
            return null;
        }
        imageView.setImageDrawable(drawable);
        return drawable;
    }

    @Nullable
    private InflatedView inflateArcLine(
            ParentViewWrapper parentViewWrapper,
            ArcLine line,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        float lengthDegrees = 0;
        if (line.hasAngularLength()) {
            if (line.getAngularLength().getInnerCase() == ArcLineLength.InnerCase.DEGREES) {
                lengthDegrees = max(0, line.getAngularLength().getDegrees().getValue());
            }
        } else {
            lengthDegrees = max(0, line.getLength().getValue());
        }

        int thicknessPx = safeDpToPx(line.getThickness());

        if (lengthDegrees == 0 && thicknessPx == 0) {
            return null;
        }

        WearCurvedLineView lineView = new WearCurvedLineView(mUiContext);

        try {
            lineView.setUpdatesEnabled(false);

            // A ArcLineView must always be the same width/height as its parent, so it can draw the
            // line properly inside of those bounds.
            ArcLayout.LayoutParams layoutParams =
                    new ArcLayout.LayoutParams(generateDefaultLayoutParams());
            layoutParams.width = LayoutParams.MATCH_PARENT;
            layoutParams.height = LayoutParams.MATCH_PARENT;

            if (line.hasBrush()) {
                lineView.setBrush(line.getBrush());
            } else if (line.hasColor()) {
                handleProp(line.getColor(), lineView::setColor, posId, pipelineMaker);
            } else {
                lineView.setColor(LINE_COLOR_DEFAULT);
            }

            if (line.hasStrokeCap()) {
                StrokeCapProp strokeCapProp = line.getStrokeCap();
                switch (strokeCapProp.getValue()) {
                    case STROKE_CAP_BUTT:
                        lineView.setStrokeCap(Cap.BUTT);
                        break;
                    case STROKE_CAP_ROUND:
                        lineView.setStrokeCap(Cap.ROUND);
                        break;
                    case STROKE_CAP_SQUARE:
                        lineView.setStrokeCap(Cap.SQUARE);
                        break;
                    case UNRECOGNIZED:
                    case STROKE_CAP_UNDEFINED:
                        Log.w(TAG, "Undefined StrokeCap value.");
                        break;
                }

                if (strokeCapProp.hasShadow()) {
                    Shadow shadow = strokeCapProp.getShadow();
                    int color =
                            shadow.getColor().hasArgb() ? shadow.getColor().getArgb() : Color.BLACK;
                    lineView.setStrokeCapShadow(
                            safeDpToPx(shadow.getBlurRadius().getValue()), color);
                }
            }

            lineView.setThickness(thicknessPx);

            DegreesProp length = DegreesProp.getDefaultInstance();

            if (line.hasAngularLength()) {
                final ArcLineLength angularLength = line.getAngularLength();
                switch (angularLength.getInnerCase()) {
                    case DEGREES:
                        length = line.getAngularLength().getDegrees();
                        handleProp(
                                length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker);
                        break;

                    case EXPANDED_ANGULAR_DIMENSION:
                        {
                            ExpandedAngularDimensionProp expandedAngularDimension =
                                    angularLength.getExpandedAngularDimension();
                            layoutParams.setWeight(
                                    expandedAngularDimension.hasLayoutWeight()
                                            ? expandedAngularDimension.getLayoutWeight().getValue()
                                            : 1.0f);
                            length = DegreesProp.getDefaultInstance();
                            break;
                        }
                    case INNER_NOT_SET:
                        break;
                }
            } else {
                length = line.getLength();
                handleProp(length, lineView::setLineSweepAngleDegrees, posId, pipelineMaker);
            }

            ArcDirection arcLineDirection =
                    line.hasArcDirection()
                            ? line.getArcDirection().getValue()
                            : ArcDirection.ARC_DIRECTION_CLOCKWISE;

            lineView.setLineDirection(arcLineDirection);

            SizedArcContainer sizeWrapper = null;
            SizedArcContainer.LayoutParams sizedLp =
                    new SizedArcContainer.LayoutParams(
                            LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT);
            @Nullable Float sizeForLayout = resolveSizeForLayoutIfNeeded(length);
            if (sizeForLayout != null) {
                sizeWrapper = new SizedArcContainer(mUiContext);
                sizeWrapper.setArcDirection(arcLineDirection);
                if (sizeForLayout <= 0f) {
                    Log.w(
                            TAG,
                            "ArcLine length's value_for_layout is not a positive value. Element"
                                    + " won't be visible.");
                }
                sizeWrapper.setSweepAngleDegrees(sizeForLayout);
                sizedLp.setAngularAlignment(
                        angularAlignmentProtoToAngularAlignment(
                                length.getAngularAlignmentForLayout()));

                // Also clamp the line to that angle...
                lineView.setMaxSweepAngleDegrees(sizeForLayout);
            }

            View wrappedView =
                    applyModifiersToArcLayoutView(
                            lineView, line.getModifiers(), posId, pipelineMaker);

            if (sizeWrapper != null) {
                sizeWrapper.addView(wrappedView, sizedLp);
                parentViewWrapper.maybeAddView(sizeWrapper, layoutParams);
                return new InflatedView(
                        sizeWrapper,
                        parentViewWrapper
                                .getParentProperties()
                                .applyPendingChildLayoutParams(layoutParams));
            } else {
                parentViewWrapper.maybeAddView(wrappedView, layoutParams);
                return new InflatedView(
                        wrappedView,
                        parentViewWrapper
                                .getParentProperties()
                                .applyPendingChildLayoutParams(layoutParams));
            }
        } finally {
            lineView.setUpdatesEnabled(true);
        }
    }

    // dereference of possibly-null reference childLayoutParams
    @SuppressWarnings("nullness:dereference.of.nullable")
    @Nullable
    private InflatedView inflateArc(
            ParentViewWrapper parentViewWrapper,
            Arc arc,
            String arcPosId,
            boolean includeChildren,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        ArcLayout arcLayout = new ArcLayout(mUiContext);
        int anchorAngleSign = 1;

        if (arc.hasArcDirection()) {
            switch (arc.getArcDirection().getValue()) {
                case ARC_DIRECTION_CLOCKWISE:
                    arcLayout.setLayoutDirection(LAYOUT_DIRECTION_LTR);
                    break;
                case ARC_DIRECTION_COUNTER_CLOCKWISE:
                    arcLayout.setLayoutDirection(LAYOUT_DIRECTION_RTL);
                    anchorAngleSign = -1;
                    break;
                case ARC_DIRECTION_NORMAL:
                    boolean isRtl = isRtlLayoutDirectionFromLocale();
                    arcLayout.setLayoutDirection(
                            isRtl ? LAYOUT_DIRECTION_RTL : LAYOUT_DIRECTION_LTR);
                    if (isRtl) {
                        anchorAngleSign = -1;
                    }
                    break;
                case UNRECOGNIZED:
                    break;
            }
        }

        LayoutParams layoutParams = generateDefaultLayoutParams();
        layoutParams.width = LayoutParams.MATCH_PARENT;
        layoutParams.height = LayoutParams.MATCH_PARENT;

        int finalAnchorAngleSign = anchorAngleSign;
        handleProp(
                arc.getAnchorAngle(),
                angle -> {
                    arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * angle);
                    // Invalidating arcLayout isn't enough. AnchorAngleDegrees change should trigger
                    // child requestLayout.
                    arcLayout.requestLayout();
                },
                arcPosId,
                pipelineMaker);
        arcLayout.setAnchorAngleDegrees(finalAnchorAngleSign * arc.getAnchorAngle().getValue());
        arcLayout.setAnchorType(anchorTypeToAnchorPos(arc.getAnchorType().getValue()));

        if (arc.hasMaxAngle()) {
            arcLayout.setMaxAngleDegrees(arc.getMaxAngle().getValue());
        }

        // Add all children.
        if (includeChildren) {
            int index = FIRST_CHILD_INDEX;
            for (ArcLayoutElement child : arc.getContentsList()) {
                String childPosId = ProtoLayoutDiffer.createNodePosId(arcPosId, index++);
                @Nullable
                InflatedView childView =
                        inflateArcLayoutElement(
                                new ParentViewWrapper(arcLayout, layoutParams),
                                child,
                                childPosId,
                                layoutInfoBuilder,
                                pipelineMaker);
                if (childView != null) {
                    ArcLayout.LayoutParams childLayoutParams =
                            (ArcLayout.LayoutParams) childView.mView.getLayoutParams();
                    boolean rotate = false;
                    if (child.hasAdapter()) {
                        rotate = child.getAdapter().getRotateContents().getValue();
                    }

                    // Apply rotation and gravity.
                    childLayoutParams.setRotated(rotate);
                    childLayoutParams.setVerticalAlignment(
                            verticalAlignmentToArcVAlign(arc.getVerticalAlign()));
                }
            }
            layoutInfoBuilder.removeSubtree(arcPosId);
        }

        View wrappedView =
                applyModifiers(
                        arcLayout,
                        /* wrapper= */ null,
                        arc.getModifiers(),
                        arcPosId,
                        pipelineMaker);
        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        int numMissingChildren = includeChildren ? 0 : arc.getContentsCount();
        return new InflatedView(
                wrappedView,
                parentViewWrapper.getParentProperties().applyPendingChildLayoutParams(layoutParams),
                NO_OP_PENDING_LAYOUT_PARAMS,
                numMissingChildren);
    }

    static boolean isRtlLayoutDirectionFromLocale() {
        return TextUtils.getLayoutDirectionFromLocale(Locale.getDefault()) == LAYOUT_DIRECTION_RTL;
    }

    private void applyStylesToSpan(
            SpannableStringBuilder builder, int start, int end, FontStyle fontStyle) {
        if (fontStyleHasSize(fontStyle)) {
            // We are using the last added size in the FontStyle because ArcText doesn't support
            // autosizing. This is the same behaviour as it was before size has made repeated.
            if (fontStyle.getSizeList().size() > 1) {
                Log.w(
                        TAG,
                        "Font size with multiple values has been used on Span Text. Ignoring "
                                + "all size except the first one.");
            }
            AbsoluteSizeSpan span =
                    new AbsoluteSizeSpan(
                            round(toPx(fontStyle.getSize(fontStyle.getSizeCount() - 1))));
            builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
        }

        if (fontStyle.hasWeight() || fontStyle.hasVariant()) {
            CustomTypefaceSpan span = new CustomTypefaceSpan(fontStyleToTypeface(fontStyle));
            builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
        }

        if (!hasDefaultTypefaceStyle(fontStyle)) {
            StyleSpan span = new StyleSpan(fontStyleToTypefaceStyle(fontStyle));
            builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
        }

        if (fontStyle.getUnderline().getValue()) {
            UnderlineSpan span = new UnderlineSpan();
            builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
        }

        if (fontStyle.hasLetterSpacing()) {
            LetterSpacingSpan span = new LetterSpacingSpan(fontStyle.getLetterSpacing().getValue());
            builder.setSpan(span, start, end, Spanned.SPAN_MARK_MARK);
        }

        ForegroundColorSpan colorSpan = new ForegroundColorSpan(extractTextColorArgb(fontStyle));

        builder.setSpan(colorSpan, start, end, Spanned.SPAN_MARK_MARK);
    }

    private static boolean fontStyleHasSize(FontStyle fontStyle) {
        return !fontStyle.getSizeList().isEmpty();
    }

    private void applyModifiersToSpan(
            SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers) {
        if (modifiers.hasClickable()) {
            ClickableSpan clickableSpan = new ProtoLayoutClickableSpan(modifiers.getClickable());

            builder.setSpan(clickableSpan, start, end, Spanned.SPAN_MARK_MARK);
        }
    }

    private SpannableStringBuilder inflateTextInSpannable(
            SpannableStringBuilder builder, SpanText text) {
        int currentPos = builder.length();
        int lastPos = currentPos + text.getText().getValue().length();

        builder.append(text.getText().getValue());

        applyStylesToSpan(builder, currentPos, lastPos, text.getFontStyle());
        applyModifiersToSpan(builder, currentPos, lastPos, text.getModifiers());

        return builder;
    }

    @SuppressWarnings("ExecutorTaskName")
    private SpannableStringBuilder inflateImageInSpannable(
            SpannableStringBuilder builder, SpanImage protoImage, TextView textView) {
        String protoResId = protoImage.getResourceId().getValue();

        if (protoImage.getWidth().getValue() == 0 || protoImage.getHeight().getValue() == 0) {
            Log.w(TAG, "One of width and height was zero on image " + protoResId);
            return builder;
        }

        ListenableFuture<Drawable> drawableFuture =
                mLayoutResourceResolvers.getDrawable(protoResId);
        if (drawableFuture.isDone()) {
            // If the future is done, immediately add drawable to builder.
            try {
                Drawable drawable = drawableFuture.get();
                appendSpanDrawable(builder, drawable, protoImage);
            } catch (ExecutionException | InterruptedException e) {
                Log.w(
                        TAG,
                        "Could not get drawable for image "
                                + protoImage.getResourceId().getValue());
            }
        } else {
            // If the future is not done, add an empty drawable to builder as a placeholder.
            @Nullable Drawable placeholderDrawable = null;

            try {
                if (mLayoutResourceResolvers.hasPlaceholderDrawable(protoResId)) {
                    placeholderDrawable =
                            mLayoutResourceResolvers.getPlaceholderDrawableOrThrow(protoResId);
                }
            } catch (ResourceAccessException | IllegalArgumentException ex) {
                Log.e(TAG, "Could not get placeholder for image " + protoResId, ex);
            }

            if (placeholderDrawable == null) {
                placeholderDrawable = new ColorDrawable(Color.TRANSPARENT);
            }

            int startInclusive = builder.length();
            FixedImageSpan placeholderDrawableSpan =
                    appendSpanDrawable(builder, placeholderDrawable, protoImage);
            int endExclusive = builder.length();

            // When the future is done, replace the empty drawable with the received one.
            drawableFuture.addListener(
                    () -> {
                        // Remove the placeholder. This should be safe, even with other modifiers
                        // applied. This just removes the single drawable span, and should leave
                        // other spans in place.
                        builder.removeSpan(placeholderDrawableSpan);
                        // Add the new drawable to the same range.
                        setSpanDrawable(
                                builder, drawableFuture, startInclusive, endExclusive, protoImage);
                        // Update the TextView.
                        textView.setText(builder);
                    },
                    ContextCompat.getMainExecutor(mUiContext));
        }

        return builder;
    }

    private FixedImageSpan appendSpanDrawable(
            SpannableStringBuilder builder, Drawable drawable, SpanImage protoImage) {
        drawable.setBounds(
                0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight()));
        FixedImageSpan imgSpan =
                new FixedImageSpan(
                        drawable,
                        spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment()));

        int startPos = builder.length();

        // Adding NBSP around the space to prevent it from being trimmed.
        builder.append(
                ZERO_WIDTH_JOINER + " " + ZERO_WIDTH_JOINER, imgSpan, Spanned.SPAN_MARK_MARK);
        int endPos = builder.length();

        applyModifiersToSpan(builder, startPos, endPos, protoImage.getModifiers());

        return imgSpan;
    }

    private void setSpanDrawable(
            SpannableStringBuilder builder,
            ListenableFuture<Drawable> drawableFuture,
            int startInclusive,
            int endExclusive,
            SpanImage protoImage) {
        final String protoResourceId = protoImage.getResourceId().getValue();

        try {
            // Add the image span to the same range occupied by the placeholder.
            Drawable drawable = drawableFuture.get();
            drawable.setBounds(
                    0, 0, safeDpToPx(protoImage.getWidth()), safeDpToPx(protoImage.getHeight()));
            FixedImageSpan imgSpan =
                    new FixedImageSpan(
                            drawable,
                            spanVerticalAlignmentToImgSpanAlignment(protoImage.getAlignment()));
            builder.setSpan(
                    imgSpan,
                    startInclusive,
                    endExclusive,
                    android.text.Spannable.SPAN_INCLUSIVE_EXCLUSIVE);
        } catch (ExecutionException | InterruptedException | CancellationException e) {
            Log.w(TAG, "Could not get drawable for image " + protoResourceId);
        }
    }

    private InflatedView inflateSpannable(
            ParentViewWrapper parentViewWrapper,
            Spannable spannable,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        TextView tv = newThemedTextView();

        // Setting colours **must** go after setting the Text Appearance, otherwise it will get
        // immediately overridden.
        if (mApplyFontVariantBodyAsDefault) {
            applyFontStyle(
                    FontStyle.getDefaultInstance(),
                    tv,
                    posId,
                    pipelineMaker,
                    /* isAutoSizeAllowed= */ false);
        }

        LayoutParams layoutParams = generateDefaultLayoutParams();

        SpannableStringBuilder builder = new SpannableStringBuilder();

        boolean isAnySpanClickable = false;

        for (Span element : spannable.getSpansList()) {
            switch (element.getInnerCase()) {
                case IMAGE:
                    SpanImage protoImage = element.getImage();
                    builder = inflateImageInSpannable(builder, protoImage, tv);

                    if (protoImage.getModifiers().hasClickable()) {
                        isAnySpanClickable = true;
                    }

                    break;
                case TEXT:
                    SpanText protoText = element.getText();
                    builder = inflateTextInSpannable(builder, protoText);

                    if (protoText.getModifiers().hasClickable()) {
                        isAnySpanClickable = true;
                    }

                    break;
                case INNER_NOT_SET:
                    Log.w(TAG, "Unknown Span child type.");
                    break;
            }
        }

        tv.setGravity(horizontalAlignmentToGravity(spannable.getMultilineAlignment().getValue()));

        if (spannable.hasMaxLines()) {
            tv.setMaxLines(max(TEXT_MIN_LINES, spannable.getMaxLines().getValue()));
        } else {
            tv.setMaxLines(TEXT_MAX_LINES_DEFAULT);
        }
        applyTextOverflow(tv, spannable.getOverflow(), spannable.getMarqueeParameters());

        if (spannable.hasLineHeight()) {
            // We use a Span here instead of just calling TextViewCompat#setLineHeight.
            // setLineHeight is implemented by taking the difference between the current font height
            // (via the font metrics, not just the size in SP), subtracting that from the desired
            // line height, and setting that as the inter-line spacing. This doesn't work for our
            // Spannables; we don't use a default height, yet TextView still has a default font (and
            // size) that it tries to base the requested line height on, despite that never actually
            // being used. The end result is that the line height never actually drops out as
            // expected.
            //
            // Instead, wrap the whole thing in a LineHeightSpan with the desired line height. This
            // gets calculated properly as the TextView is calculating its per-line font metrics,
            // and will actually work correctly.
            StandardLineHeightSpan span =
                    new StandardLineHeightSpan((int) toPx(spannable.getLineHeight()));
            builder.setSpan(
                    span,
                    /* start= */ 0,
                    /* end= */ builder.length(),
                    Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
        } else if (spannable.hasLineSpacing()) {
            tv.setLineSpacing(toPx(spannable.getLineSpacing()), 1f);
        }

        tv.setText(builder);

        // AndroidTextStyle proto is existing only for newer builders and older renderer mix to also
        // have excluded font padding. Here, it's being ignored, and default value (excluded font
        // padding) is used.
        applyExcludeFontPadding(tv);

        if (isAnySpanClickable) {
            // For any ClickableSpans to work, the MovementMethod must be set to LinkMovementMethod.
            tv.setMovementMethod(LinkMovementMethod.getInstance());

            // Disable the highlight color; if we don't do this, the clicked span will get
            // highlighted, which will be cleared half a second later if using LoadAction as the
            // next layout will be delivered, which recreates the elements and clears the highlight.
            tv.setHighlightColor(Color.TRANSPARENT);

            // Use InhibitingScroller to prevent the text from scrolling when tapped. Setting a
            // MovementMethod on a TextView (e.g. for clickables in a Spannable) then cause the
            // TextView to be scrollable, and to jump to the end when tapped.
            tv.setScroller(new InhibitingScroller(mUiContext));
        }

        View wrappedView =
                applyModifiers(
                        tv, /* wrapper= */ null, spannable.getModifiers(), posId, pipelineMaker);
        parentViewWrapper.maybeAddView(wrappedView, layoutParams);

        return new InflatedView(
                wrappedView,
                parentViewWrapper
                        .getParentProperties()
                        .applyPendingChildLayoutParams(layoutParams));
    }

    @Nullable
    private InflatedView inflateArcLayoutElement(
            ParentViewWrapper parentViewWrapper,
            ArcLayoutElement element,
            String nodePosId,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        InflatedView inflatedView = null;

        switch (element.getInnerCase()) {
            case ADAPTER:
                if (hasTransformation(element.getAdapter().getContent())) {
                    Log.e(
                            TAG,
                            "Error inflating "
                                    + element.getAdapter().getContent().getInnerCase().name()
                                    + " in the arc. Transformation modifier is not supported for "
                                    + "the layout element"
                                    + "  inside an ArcAdapter.");
                    inflatedView = null;
                } else {
                    // Fall back to the normal inflater.
                    inflatedView =
                            inflateLayoutElement(
                                    parentViewWrapper,
                                    element.getAdapter().getContent(),
                                    nodePosId,
                                    /* includeChildren= */ true,
                                    layoutInfoBuilder,
                                    pipelineMaker);
                }
                break;

            case SPACER:
                inflatedView =
                        inflateArcSpacer(
                                parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker);
                break;

            case LINE:
                inflatedView =
                        inflateArcLine(
                                parentViewWrapper, element.getLine(), nodePosId, pipelineMaker);
                break;

            case TEXT:
                inflatedView =
                        inflateArcText(
                                parentViewWrapper, element.getText(), nodePosId, pipelineMaker);
                break;

            case INNER_NOT_SET:
                break;
        }

        if (inflatedView == null) {
            // Covers null (returned when the childCase in the proto isn't known). Sadly, ProtoLite
            // doesn't give us a way to access childCase's underlying tag, so we can't give any
            // smarter error message here.
            Log.w(TAG, "Unknown child type");
        } else if (nodePosId.isEmpty()) {
            Log.w(TAG, "No node ID for " + element.getInnerCase().name());
        } else {
            // Set the view's tag to a known, position-based ID so that it can be looked up to apply
            // mutations.
            inflatedView.mView.setTag(nodePosId);
            if (inflatedView.mView instanceof ViewGroup) {
                layoutInfoBuilder.add(
                        nodePosId,
                        ViewProperties.fromViewGroup(
                                (ViewGroup) inflatedView.mView,
                                inflatedView.mLayoutParams,
                                inflatedView.mChildLayoutParams));
            }
            pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId));
        }
        return inflatedView;
    }

    /** Checks whether a layout element has a transformation modifier. */
    private static boolean hasTransformation(@NonNull LayoutElement content) {
        switch (content.getInnerCase()) {
            case IMAGE:
                return content.getImage().hasModifiers()
                        && content.getImage().getModifiers().hasTransformation();
            case TEXT:
                return content.getText().hasModifiers()
                        && content.getText().getModifiers().hasTransformation();
            case SPACER:
                return content.getSpacer().hasModifiers()
                        && content.getSpacer().getModifiers().hasTransformation();
            case BOX:
                return content.getBox().hasModifiers()
                        && content.getBox().getModifiers().hasTransformation();
            case ROW:
                return content.getRow().hasModifiers()
                        && content.getRow().getModifiers().hasTransformation();
            case COLUMN:
                return content.getColumn().hasModifiers()
                        && content.getColumn().getModifiers().hasTransformation();
            case SPANNABLE:
                return content.getSpannable().hasModifiers()
                        && content.getSpannable().getModifiers().hasTransformation();
            case ARC:
                return content.getArc().hasModifiers()
                        && content.getArc().getModifiers().hasTransformation();
            case EXTENSION:
                // fall through
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    @Nullable
    private InflatedView inflateLayoutElement(
            ParentViewWrapper parentViewWrapper,
            LayoutElement element,
            String nodePosId,
            boolean includeChildren,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        InflatedView inflatedView = null;
        // What is it?
        switch (element.getInnerCase()) {
            case COLUMN:
                inflatedView =
                        inflateColumn(
                                parentViewWrapper,
                                element.getColumn(),
                                nodePosId,
                                includeChildren,
                                layoutInfoBuilder,
                                pipelineMaker);
                break;
            case ROW:
                inflatedView =
                        inflateRow(
                                parentViewWrapper,
                                element.getRow(),
                                nodePosId,
                                includeChildren,
                                layoutInfoBuilder,
                                pipelineMaker);
                break;
            case BOX:
                inflatedView =
                        inflateBox(
                                parentViewWrapper,
                                element.getBox(),
                                nodePosId,
                                includeChildren,
                                layoutInfoBuilder,
                                pipelineMaker);
                break;
            case SPACER:
                inflatedView =
                        inflateSpacer(
                                parentViewWrapper, element.getSpacer(), nodePosId, pipelineMaker);
                break;
            case TEXT:
                inflatedView =
                        inflateText(parentViewWrapper, element.getText(), nodePosId, pipelineMaker);
                break;
            case IMAGE:
                inflatedView =
                        inflateImage(
                                parentViewWrapper, element.getImage(), nodePosId, pipelineMaker);
                break;
            case ARC:
                inflatedView =
                        inflateArc(
                                parentViewWrapper,
                                element.getArc(),
                                nodePosId,
                                includeChildren,
                                layoutInfoBuilder,
                                pipelineMaker);
                break;
            case SPANNABLE:
                inflatedView =
                        inflateSpannable(
                                parentViewWrapper,
                                element.getSpannable(),
                                nodePosId,
                                pipelineMaker);
                break;
            case 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());
                break;
        }

        if (inflatedView == null) {
            Log.w(TAG, "Error inflating " + element.getInnerCase().name());
        } else if (nodePosId.isEmpty()) {
            Log.w(TAG, "No node ID for " + element.getInnerCase().name());
        } else {
            // Set the view's tag to a known, position-based ID so that it can be looked up to apply
            // mutations.
            inflatedView.mView.setTag(nodePosId);
            if (inflatedView.mView instanceof ViewGroup) {
                layoutInfoBuilder.add(
                        nodePosId,
                        ViewProperties.fromViewGroup(
                                (ViewGroup) inflatedView.mView,
                                inflatedView.mLayoutParams,
                                inflatedView.mChildLayoutParams));
            }
            pipelineMaker.ifPresent(pipe -> pipe.rememberNode(nodePosId));
        }
        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.
     *
     * <p>If both are set, this routine will yield the constant value if and only if this renderer
     * has a dynamic pipeline (i.e. {code mDataPipeline} is non-null), otherwise it will only
     * subscribe for dynamic updates. If the dynamic pipeline ever yields an invalid value (via
     * {@code onStateInvalid}), then stringProp's static valid will be used instead.
     */
    private void handleProp(
            StringProp stringProp,
            Consumer<String> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (stringProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker
                        .get()
                        .addPipelineFor(
                                stringProp.getDynamicValue(),
                                stringProp.getValue(),
                                mUiContext.getResources().getConfiguration().getLocales().get(0),
                                posId,
                                consumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                consumer.accept(stringProp.getValue());
            }
        } else {
            consumer.accept(stringProp.getValue());
        }
    }

    private void handleProp(
            DegreesProp degreesProp,
            Consumer<Float> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (degreesProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker
                        .get()
                        .addPipelineFor(degreesProp, degreesProp.getValue(), posId, consumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                consumer.accept(degreesProp.getValue());
            }
        } else {
            consumer.accept(degreesProp.getValue());
        }
    }

    private void handleProp(
            DpProp dpProp,
            Consumer<Float> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        handleProp(dpProp, consumer, consumer, posId, pipelineMaker);
    }

    private void handleProp(
            DpProp dpProp,
            Consumer<Float> staticValueConsumer,
            Consumer<Float> dynamicValueConsumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (dpProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker
                        .get()
                        .addPipelineFor(dpProp, dpProp.getValue(), posId, dynamicValueConsumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                staticValueConsumer.accept(dpProp.getValue());
            }
        } else {
            staticValueConsumer.accept(dpProp.getValue());
        }
    }

    private void handleProp(
            ColorProp colorProp,
            Consumer<Integer> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (colorProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker.get().addPipelineFor(colorProp, colorProp.getArgb(), posId, consumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                consumer.accept(colorProp.getArgb());
            }
        } else {
            consumer.accept(colorProp.getArgb());
        }
    }

    private void handleProp(
            BoolProp boolProp,
            Consumer<Boolean> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (boolProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker.get().addPipelineFor(boolProp, boolProp.getValue(), posId, consumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                consumer.accept(boolProp.getValue());
            }
        } else {
            consumer.accept(boolProp.getValue());
        }
    }

    private void handleProp(
            FloatProp floatProp,
            Consumer<Float> consumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        handleProp(floatProp, consumer, consumer, posId, pipelineMaker);
    }

    private void handleProp(
            FloatProp floatProp,
            Consumer<Float> staticValueConsumer,
            Consumer<Float> dynamicValueconsumer,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        if (floatProp.hasDynamicValue() && pipelineMaker.isPresent()) {
            try {
                pipelineMaker
                        .get()
                        .addPipelineFor(
                                floatProp.getDynamicValue(),
                                floatProp.getValue(),
                                posId,
                                dynamicValueconsumer);
            } catch (RuntimeException ex) {
                Log.e(TAG, "Error building pipeline", ex);
                staticValueConsumer.accept(floatProp.getValue());
            }
        } else {
            staticValueConsumer.accept(floatProp.getValue());
        }
    }

    /**
     * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
     * values. Returns null if no size wrapper is needed.
     */
    @Nullable
    private String resolveValueForLayoutIfNeeded(StringProp stringProp) {
        if (!stringProp.hasDynamicValue() || !mDataPipeline.isPresent()) {
            return null;
        }

        // If value_for_layout is set to non-zero, always use it.
        if (!stringProp.getValueForLayout().isEmpty()) {
            return stringProp.getValueForLayout();
        }

        return mAllowLayoutChangingBindsWithoutDefault ? null : "";
    }

    /**
     * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
     * values. Returns null if no size wrapper is needed.
     */
    @Nullable
    private Float resolveSizeForLayoutIfNeeded(SpacerDimension spacerDimension) {
        DpProp dimension = spacerDimension.getLinearDimension();
        if (!dimension.hasDynamicValue() || !mDataPipeline.isPresent()) {
            return null;
        }

        if (dimension.getValueForLayout() > 0f) {
            return dimension.getValueForLayout();
        }

        return mAllowLayoutChangingBindsWithoutDefault ? null : 0f;
    }

    /**
     * Resolves the value for layout to be used in a Size Wrapper for elements containing dynamic
     * values. Returns null if no size wrapper is needed.
     */
    @Nullable
    private Float resolveSizeForLayoutIfNeeded(DegreesProp degreesProp) {
        if (!degreesProp.hasDynamicValue() || !mDataPipeline.isPresent()) {
            return null;
        }

        // If value_for_layout is set to non-zero, always use it
        if (degreesProp.getValueForLayout() > 0f) {
            return degreesProp.getValueForLayout();
        }

        return mAllowLayoutChangingBindsWithoutDefault ? null : 0f;
    }

    private boolean canMeasureContainer(
            ContainerDimension containerWidth,
            ContainerDimension containerHeight,
            List<LayoutElement> elements) {
        // We can't measure a container if it's set to wrap-contents but all of its contents are set
        // to expand-to-parent. Such containers must not be displayed.
        if (containerWidth.hasWrappedDimension()
                && !containsMeasurableWidth(containerHeight, elements)) {
            return false;
        }
        return !containerHeight.hasWrappedDimension()
                || containsMeasurableHeight(containerWidth, elements);
    }

    private boolean containsMeasurableWidth(
            ContainerDimension containerHeight, List<LayoutElement> elements) {
        for (LayoutElement element : elements) {
            if (isWidthMeasurable(element, containerHeight)) {
                // Enough to find a single element that is measurable.
                return true;
            }
        }
        return false;
    }

    private boolean containsMeasurableHeight(
            ContainerDimension containerWidth, List<LayoutElement> elements) {
        for (LayoutElement element : elements) {
            if (isHeightMeasurable(element, containerWidth)) {
                // Enough to find a single element that is measurable.
                return true;
            }
        }
        return false;
    }

    private boolean isWidthMeasurable(LayoutElement element, ContainerDimension containerHeight) {
        switch (element.getInnerCase()) {
            case COLUMN:
                return isMeasurable(element.getColumn().getWidth());
            case ROW:
                return isMeasurable(element.getRow().getWidth());
            case BOX:
                return isMeasurable(element.getBox().getWidth());
            case SPACER:
                return isMeasurable(element.getSpacer().getWidth());
            case IMAGE:
                // Special-case. If the image width is proportional, then the height must be
                // measurable. This means either a fixed size, or expanded where we know the parent
                // dimension.
                Image img = element.getImage();
                if (img.getWidth().hasProportionalDimension()) {
                    boolean isContainerHeightKnown =
                            (containerHeight.hasExpandedDimension()
                                    || containerHeight.hasLinearDimension());
                    return img.getHeight().hasLinearDimension()
                            || (img.getHeight().hasExpandedDimension() && isContainerHeightKnown);
                } else {
                    return isMeasurable(element.getImage().getWidth());
                }
            case ARC:
            case TEXT:
            case SPANNABLE:
                return true;
            case EXTENSION:
                return isMeasurable(element.getExtension().getWidth());
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private boolean isHeightMeasurable(LayoutElement element, ContainerDimension containerWidth) {
        switch (element.getInnerCase()) {
            case COLUMN:
                return isMeasurable(element.getColumn().getHeight());
            case ROW:
                return isMeasurable(element.getRow().getHeight());
            case BOX:
                return isMeasurable(element.getBox().getHeight());
            case SPACER:
                return isMeasurable(element.getSpacer().getHeight());
            case IMAGE:
                // Special-case. If the image height is proportional, then the width must be
                // measurable. This means either a fixed size, or expanded where we know the parent
                // dimension.
                Image img = element.getImage();
                if (img.getHeight().hasProportionalDimension()) {
                    boolean isContainerWidthKnown =
                            (containerWidth.hasExpandedDimension()
                                    || containerWidth.hasLinearDimension());
                    return img.getWidth().hasLinearDimension()
                            || (img.getWidth().hasExpandedDimension() && isContainerWidthKnown);
                } else {
                    return isMeasurable(element.getImage().getHeight());
                }
            case ARC:
            case TEXT:
            case SPANNABLE:
                return true;
            case EXTENSION:
                return isMeasurable(element.getExtension().getHeight());
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private boolean isMeasurable(ContainerDimension dimension) {
        return dimensionToPx(dimension) != LayoutParams.MATCH_PARENT;
    }

    private static boolean isMeasurable(ImageDimension dimension) {
        switch (dimension.getInnerCase()) {
            case LINEAR_DIMENSION:
            case PROPORTIONAL_DIMENSION:
                return true;
            case EXPANDED_DIMENSION:
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private static boolean isMeasurable(SpacerDimension dimension) {
        switch (dimension.getInnerCase()) {
            case LINEAR_DIMENSION:
                return true;
            case EXPANDED_DIMENSION:
                return false;
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private static boolean isMeasurable(ExtensionDimension dimension) {
        switch (dimension.getInnerCase()) {
            case LINEAR_DIMENSION:
                return true;
            case INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private void inflateChildElements(
            @NonNull ViewGroup parent,
            @NonNull LayoutParams parentLayoutParams,
            PendingLayoutParams childLayoutParams,
            List<LayoutElement> childElements,
            String parentPosId,
            LayoutInfo.Builder layoutInfoBuilder,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        int index = FIRST_CHILD_INDEX;
        for (LayoutElement childElement : childElements) {
            String childPosId = ProtoLayoutDiffer.createNodePosId(parentPosId, index++);
            inflateLayoutElement(
                    new ParentViewWrapper(parent, parentLayoutParams, childLayoutParams),
                    childElement,
                    childPosId,
                    /* includeChildren= */ true,
                    layoutInfoBuilder,
                    pipelineMaker);
        }
    }

    /**
     * Inflates a ProtoLayout into {@code inflateParent}.
     *
     * @param inflateParent The view to attach the layout into.
     * @return The {@link InflateResult} class containing the first child that was inflated,
     *     animations to be played, and new nodes for the dynamic data pipeline. Callers should use
     *     {@link InflateResult#updateDynamicDataPipeline} to apply those changes using a UI Thread.
     *     <p>This may be null if the proto is empty the top-level LayoutElement has no inner set,
     *     or the top-level LayoutElement contains an unsupported inner type.
     */
    @Nullable
    public InflateResult inflate(@NonNull ViewGroup inflateParent) {

        // This is a full re-inflation, so we don't need any previous rendering information.
        LayoutInfo.Builder layoutInfoBuilder =
                new LayoutInfo.Builder(/* previousLayoutInfo= */ null);

        // Go!
        Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker =
                mDataPipeline.map(
                        p ->
                                p.newPipelineMaker(
                                        ProtoLayoutInflater::getEnterAnimations,
                                        ProtoLayoutInflater::getExitAnimations));
        InflatedView firstInflatedChild =
                inflateLayoutElement(
                        new ParentViewWrapper(
                                inflateParent,
                                new LayoutParams(
                                        LayoutParams.MATCH_PARENT, LayoutParams.MATCH_PARENT)),
                        mLayoutProto.getRoot(),
                        ROOT_NODE_ID,
                        /* includeChildren= */ true,
                        layoutInfoBuilder,
                        pipelineMaker);
        if (firstInflatedChild == null) {
            return null;
        }
        if (mLayoutProto.hasFingerprint()) {
            inflateParent.setTag(
                    R.id.rendered_metadata_tag,
                    new RenderedMetadata(mLayoutProto.getFingerprint(), layoutInfoBuilder.build()));
        }
        return new InflateResult(inflateParent, firstInflatedChild.mView, pipelineMaker);
    }

    /**
     * Compute the mutation that must be applied to the given {@link ViewGroup} in order to produce
     * the given target layout.
     *
     * <p>If the return value is {@code null}, {@code parent} must be updated in full using {@link
     * #inflate}. Otherwise, call {ViewGroupMutation#isNoOp} on the return value to check if there
     * are any mutations to apply and call {@link #applyMutation} to apply them.
     *
     * <p>Can be called from a background thread.
     *
     * @param prevRenderedMetadata The metadata for the previous rendering of this view, either
     *     using {@code inflate} or {@code applyMutation}. This can be retrieved by calling {@link
     *     #getRenderedMetadata} on the previous layout view parent.
     * @param targetLayout The target layout that the mutation should result in.
     * @return The mutation that will produce the target layout.
     */
    @Nullable
    public ViewGroupMutation computeMutation(
            @NonNull RenderedMetadata prevRenderedMetadata,
            @NonNull Layout targetLayout,
            @NonNull ViewProperties parentViewProp) {
        if (prevRenderedMetadata.getTreeFingerprint() == null) {
            Log.w(TAG, "No previous fingerprint available.");
            return null;
        }
        @Nullable
        LayoutDiff diff =
                ProtoLayoutDiffer.getDiff(prevRenderedMetadata.getTreeFingerprint(), targetLayout);
        if (diff == null) {
            Log.w(TAG, "getDiff failed");
            return null;
        }

        logDebug(diff);

        List<InflatedView> inflatedViews = new ArrayList<>();
        LayoutInfo.Builder layoutInfoBuilder =
                new LayoutInfo.Builder(prevRenderedMetadata.getLayoutInfo());
        LayoutInfo prevLayoutInfo = prevRenderedMetadata.getLayoutInfo();
        Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker =
                mDataPipeline.map(
                        p ->
                                p.newPipelineMaker(
                                        ProtoLayoutInflater::getEnterAnimations,
                                        ProtoLayoutInflater::getExitAnimations));
        for (TreeNodeWithChange changedNode : diff.getChangedNodes()) {
            String nodePosId = changedNode.getPosId();
            if (nodePosId.isEmpty()) {
                // Failed to compute mutation. Need to update fully.
                Log.w(TAG, "Empty nodePosId");
                return null;
            }
            ViewProperties parentInfo;
            if (nodePosId.equals(ROOT_NODE_ID)) {
                parentInfo = parentViewProp;
            } else {
                String parentNodePosId = getParentNodePosId(nodePosId);
                if (parentNodePosId == null || !prevLayoutInfo.contains(parentNodePosId)) {
                    // Failed to compute mutation. Need to update fully.
                    Log.w(TAG, "Can't find view " + nodePosId);
                    return null;
                }

                // The parent node might also have been updated.
                @Nullable
                ViewProperties possibleUpdatedParentInfo =
                        layoutInfoBuilder.getViewPropertiesFor(parentNodePosId);
                parentInfo =
                        possibleUpdatedParentInfo != null
                                ? possibleUpdatedParentInfo
                                : checkNotNull(
                                        prevLayoutInfo.getViewPropertiesFor(parentNodePosId));
            }
            InflatedView inflatedView = null;
            @Nullable LayoutElement updatedLayoutElement = changedNode.getLayoutElement();
            @Nullable ArcLayoutElement updatedArcLayoutElement = changedNode.getArcLayoutElement();
            if (updatedLayoutElement != null) {
                inflatedView =
                        inflateLayoutElement(
                                new ParentViewWrapper(parentInfo),
                                updatedLayoutElement,
                                nodePosId,
                                !changedNode.isSelfOnlyChange(),
                                layoutInfoBuilder,
                                pipelineMaker);
            } else if (updatedArcLayoutElement != null) {
                inflatedView =
                        inflateArcLayoutElement(
                                new ParentViewWrapper(parentInfo),
                                updatedArcLayoutElement,
                                nodePosId,
                                layoutInfoBuilder,
                                pipelineMaker);
            }
            if (inflatedView == null) {
                // Failed to compute mutation. Need to update fully.
                Log.w(TAG, "No inflatedView");
                return null;
            }
            inflatedViews.add(inflatedView);

            if (!ProtoLayoutDiffer.UPDATE_ALL_CHILDREN_AFTER_ADD_REMOVE) {

                throw new UnsupportedOperationException();
            }
            if (!changedNode.isSelfOnlyChange()) {
                // A child addition/removal causes a full reinflation of the parent. So the only
                // case that we might not replace a node in pipeline is when it's removed as part of
                // a parent change.
                pipelineMaker.ifPresent(p -> p.markForChildRemoval(nodePosId));
            }
            pipelineMaker.ifPresent(
                    p -> p.markNodeAsChanged(nodePosId, !changedNode.isSelfOnlyChange()));
        }
        return new ViewGroupMutation(
                inflatedViews,
                new RenderedMetadata(targetLayout.getFingerprint(), layoutInfoBuilder.build()),
                prevRenderedMetadata.getTreeFingerprint().getRoot(),
                pipelineMaker);
    }

    /** Apply the mutation that was previously computed with {@link #computeMutation}. */
    @UiThread
    @NonNull
    public ListenableFuture<RenderingArtifact> applyMutation(
            @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) {
        RenderedMetadata prevRenderedMetadata = getRenderedMetadata(prevInflatedParent);
        if (prevRenderedMetadata != null
                && !ProtoLayoutDiffer.areNodesEquivalent(
                        prevRenderedMetadata.getTreeFingerprint().getRoot(),
                        groupMutation.mPreMutationRootNodeFingerprint)) {

            // be considered unequal. Log.e(TAG, "View has changed. Skipping mutation."); return
            // false;
        }
        if (groupMutation.isNoOp()) {
            // Nothing to do.
            return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger));
        }

        if (groupMutation.mPipelineMaker.isPresent()) {
            SettableFuture<RenderingArtifact> result = SettableFuture.create();
            groupMutation
                    .mPipelineMaker
                    .get()
                    .playExitAnimations(
                            prevInflatedParent,
                            /* isReattaching= */ false,
                            () -> {
                                try {
                                    applyMutationInternal(prevInflatedParent, groupMutation);
                                    result.set(RenderingArtifact.create(mInflaterStatsLogger));
                                } catch (ViewMutationException ex) {
                                    result.setException(ex);
                                }
                            });
            return result;
        } else {
            try {
                applyMutationInternal(prevInflatedParent, groupMutation);
                return immediateFuture(RenderingArtifact.create(mInflaterStatsLogger));
            } catch (ViewMutationException ex) {
                return immediateFailedFuture(ex);
            }
        }
    }

    private void applyMutationInternal(
            @NonNull ViewGroup prevInflatedParent, @NonNull ViewGroupMutation groupMutation) {
        mInflaterStatsLogger.logMutationChangedNodes(groupMutation.mInflatedViews.size());
        for (InflatedView inflatedView : groupMutation.mInflatedViews) {
            String posId = inflatedView.getTag();
            if (posId == null) {
                // Failed to apply the mutation. Need to update fully.
                throw new ViewMutationException("View has no tag");
            }
            View viewToUpdate = prevInflatedParent.findViewWithTag(posId);
            if (viewToUpdate == null) {
                // Failed to apply the mutation. Need to update fully.
                throw new ViewMutationException("Can't find view " + posId);
            }
            ViewParent potentialImmediateParent = viewToUpdate.getParent();
            if (!(potentialImmediateParent instanceof ViewGroup)) {
                // Failed to apply the mutation. Need to update fully.
                throw new ViewMutationException("Parent not a ViewGroup");
            }
            ViewGroup immediateParent = (ViewGroup) potentialImmediateParent;
            int childIndex = immediateParent.indexOfChild(viewToUpdate);
            if (childIndex == -1) {
                // Failed to apply the mutation. Need to update fully.
                throw new ViewMutationException("Can't find child at " + childIndex);
            }
            if (!inflatedView.addMissingChildrenFrom(viewToUpdate)) {
                throw new ViewMutationException("Failed to add missing children " + posId);
            }
            // Remove the touch delegate to the view to be updated
            if (immediateParent.getTouchDelegate() != null) {
                TouchDelegateComposite delegateComposite =
                        (TouchDelegateComposite) immediateParent.getTouchDelegate();
                delegateComposite.removeDelegate(viewToUpdate);

                // Make sure to remove the touch delegate when the actual clickable view is wrapped,
                // for example ImageView inside the RatioViewWrapper
                if (viewToUpdate instanceof ViewGroup
                        && ((ViewGroup) viewToUpdate).getChildCount() > 0) {
                    delegateComposite.removeDelegate(((ViewGroup) viewToUpdate).getChildAt(0));
                }

                // If no more touch delegate left in the composite, remove it completely from the
                // parent
                if (delegateComposite.isEmpty()) {
                    immediateParent.setTouchDelegate(null);
                }
            }
            immediateParent.removeViewAt(childIndex);
            immediateParent.addView(inflatedView.mView, childIndex, inflatedView.mLayoutParams);

            if (DEBUG_DIFF_UPDATE_ENABLED) {
                // Visualize diff update (by flashing the inflated element).
                inflatedView
                        .mView
                        .animate()
                        .alpha(0.7f)
                        .setDuration(50)
                        .withEndAction(() -> inflatedView.mView.animate().alpha(1).setDuration(50));
            }
        }
        groupMutation.mPipelineMaker.ifPresent(
                pipe -> pipe.commit(prevInflatedParent, /* isReattaching= */ false));
        prevInflatedParent.setTag(
                R.id.rendered_metadata_tag, groupMutation.mRenderedMetadataAfterMutation);
    }

    /** Returns the {@link RenderedMetadata} attached to {@code inflateParent}. */
    @UiThread
    @Nullable
    public static RenderedMetadata getRenderedMetadata(@NonNull ViewGroup inflateParent) {
        Object prevMetadataObject = inflateParent.getTag(R.id.rendered_metadata_tag);
        if (prevMetadataObject instanceof RenderedMetadata) {
            return (RenderedMetadata) prevMetadataObject;
        } else {
            if (prevMetadataObject != null) {
                Log.w(TAG, "Incompatible prevMetadataObject");
            }
            return null;
        }
    }

    /** Clears the {@link RenderedMetadata} attached to {@code inflateParent}. */
    @UiThread
    public static void clearRenderedMetadata(@NonNull ViewGroup inflateParent) {
        Log.d(TAG, "Clearing rendered metadata. Next inflation won't use diff update.");
        inflateParent.setTag(R.id.rendered_metadata_tag, /* tag= */ null);
    }

    // dereference of possibly-null reference ((FrameLayout.LayoutParams)child.getLayoutParams())
    @SuppressWarnings("nullness:dereference.of.nullable")
    private static void applyGravityToFrameLayoutChildren(FrameLayout parent, int gravity) {
        for (int i = 0; i < parent.getChildCount(); i++) {
            View child = parent.getChildAt(i);

            // All children should have a LayoutParams already set...
            if (!(child.getLayoutParams() instanceof FrameLayout.LayoutParams)) {
                // This...shouldn't happen.
                throw new IllegalStateException(
                        "Layout params of child is not a descendant of FrameLayout.LayoutParams.");
            }

            // Children should grow out from the middle of the layout.
            ((FrameLayout.LayoutParams) child.getLayoutParams()).gravity = gravity;
        }
    }

    static String roleToClassName(SemanticsRole role) {
        switch (role) {
            case SEMANTICS_ROLE_IMAGE:
                return "android.widget.ImageView";
            case SEMANTICS_ROLE_BUTTON:
                return "android.widget.Button";
            case SEMANTICS_ROLE_CHECKBOX:
                return "android.widget.CheckBox";
            case SEMANTICS_ROLE_SWITCH:
                return "android.widget.Switch";
            case SEMANTICS_ROLE_RADIOBUTTON:
                return "android.widget.RadioButton";
            case SEMANTICS_ROLE_NONE:
            case UNRECOGNIZED:
                return "";
        }
        return "";
    }

    // getObsoleteContentDescription is used for backward compatibility
    @SuppressWarnings("deprecation")
    private void applySemantics(
            View view,
            Semantics semantics,
            String posId,
            Optional<ProtoLayoutDynamicDataPipeline.PipelineMaker> pipelineMaker) {
        view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
        ViewCompat.setAccessibilityDelegate(
                view,
                new AccessibilityDelegateCompat() {
                    @Override
                    public void onInitializeAccessibilityNodeInfo(
                            @NonNull View host, @NonNull AccessibilityNodeInfoCompat info) {
                        super.onInitializeAccessibilityNodeInfo(host, info);

                        String className = roleToClassName(semantics.getRole());
                        if (!className.isEmpty()) {
                            info.setClassName(className);
                        }
                        info.setFocusable(true);
                        info.setImportantForAccessibility(true);
                    }
                });

        if (semantics.hasContentDescription()) {
            handleProp(
                    semantics.getContentDescription(),
                    view::setContentDescription,
                    posId,
                    pipelineMaker);
        } else {
            // This is for backward compatibility
            view.setContentDescription(semantics.getObsoleteContentDescription());
        }

        if (semantics.hasStateDescription()) {
            handleProp(
                    semantics.getStateDescription(),
                    (state) -> ViewCompat.setStateDescription(view, state),
                    posId,
                    pipelineMaker);
        }
    }

    /** Creates a TextView with the fallbackTextAppearance from the current theme. */
    private TextView newThemedTextView() {
        return new TextView(
                mProtoLayoutThemeContext,
                /* attrs= */ null,
                mProtoLayoutTheme.getFallbackTextAppearanceResId());
    }

    /** Creates a CurvedTextView with the fallbackTextAppearance from the current theme. */
    private CurvedTextView newThemedCurvedTextView() {
        return new CurvedTextView(
                mProtoLayoutThemeContext,
                /* attrs= */ null,
                mProtoLayoutTheme.getFallbackTextAppearanceResId());
    }

    private void logDebug(LayoutDiff diff) {
        if (mLoggingUtils != null && mLoggingUtils.canLogD(TAG)) {
            StringBuilder sb =
                    new StringBuilder("LayoutDiff result at LayoutInflater#computeMutation: \n");
            List<TreeNodeWithChange> diffNodes = diff.getChangedNodes();
            if (diffNodes.isEmpty()) {
                mLoggingUtils.logD(TAG, "No diff.");
                return;
            }
            for (TreeNodeWithChange changedNode : diffNodes) {
                sb.append(formatNodeChangeForLogs(changedNode));
                if (changedNode.getLayoutElement() != null) {
                    sb.append(changedNode.getLayoutElement());
                } else if (changedNode.getArcLayoutElement() != null) {
                    sb.append(changedNode.getArcLayoutElement());
                }
                sb.append("\n");
            }
            mLoggingUtils.logD(TAG, sb.toString());
        }
    }

    private static String formatNodeChangeForLogs(TreeNodeWithChange change) {
        return "PosId: "
                + change.getPosId()
                + " | Fingerprint: "
                + change.getFingerprint().getSelfTypeValue()
                + " | isSelfOnlyChange: "
                + change.isSelfOnlyChange();
    }

    /** Implementation of ClickableSpan for ProtoLayout's Clickables. */
    private class ProtoLayoutClickableSpan extends ClickableSpan {
        private final Clickable mClickable;

        ProtoLayoutClickableSpan(@NonNull Clickable clickable) {
            this.mClickable = clickable;
        }

        @Override
        public void onClick(@NonNull View widget) {
            Action action = mClickable.getOnClick();

            switch (action.getValueCase()) {
                case LAUNCH_ACTION:
                    Intent i =
                            buildLaunchActionIntent(
                                    action.getLaunchAction(),
                                    mClickable.getId(),
                                    mClickableIdExtra);
                    if (i != null) {
                        dispatchLaunchActionIntent(i);
                    }
                    break;
                case LOAD_ACTION:
                    if (mLoadActionExecutor == null) {
                        Log.w(TAG, "Ignoring load action since an executor has not been provided.");
                        break;
                    }
                    mLoadActionExecutor.execute(
                            () ->
                                    mLoadActionListener.onClick(
                                            buildState(
                                                    action.getLoadAction(), mClickable.getId())));
                    break;
                case VALUE_NOT_SET:
                    break;
            }
        }

        @Override
        public void updateDrawState(@NonNull TextPaint ds) {
            // Don't change the underlying text appearance.
        }
    }

    /** Implementation of {@link Scroller} which inhibits all scrolling. */
    private static class InhibitingScroller extends Scroller {
        InhibitingScroller(Context context) {
            super(context);
        }

        @Override
        public void startScroll(int startX, int startY, int dx, int dy) {}

        @Override
        public void startScroll(int startX, int startY, int dx, int dy, int duration) {}
    }
}