TileRendererInternal.java

/*
 * Copyright 2021 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.tiles.renderer.internal;

import static androidx.core.util.Preconditions.checkNotNull;

import static java.lang.Math.max;
import static java.lang.Math.round;

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.TypedArray;
import android.graphics.Canvas;
import android.graphics.Color;
import android.graphics.Paint;
import android.graphics.PorterDuff.Mode;
import android.graphics.Rect;
import android.graphics.Typeface;
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.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.MetricAffectingSpan;
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.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.StyleRes;
import androidx.core.content.ContextCompat;
import androidx.wear.tiles.TileService;
import androidx.wear.tiles.proto.ActionProto.Action;
import androidx.wear.tiles.proto.ActionProto.AndroidActivity;
import androidx.wear.tiles.proto.ActionProto.AndroidExtra;
import androidx.wear.tiles.proto.ActionProto.LaunchAction;
import androidx.wear.tiles.proto.ActionProto.LoadAction;
import androidx.wear.tiles.proto.DimensionProto.ContainerDimension;
import androidx.wear.tiles.proto.DimensionProto.ContainerDimension.InnerCase;
import androidx.wear.tiles.proto.DimensionProto.DpProp;
import androidx.wear.tiles.proto.DimensionProto.ExpandedDimensionProp;
import androidx.wear.tiles.proto.DimensionProto.ImageDimension;
import androidx.wear.tiles.proto.DimensionProto.ProportionalDimensionProp;
import androidx.wear.tiles.proto.DimensionProto.SpProp;
import androidx.wear.tiles.proto.DimensionProto.SpacerDimension;
import androidx.wear.tiles.proto.DimensionProto.WrappedDimensionProp;
import androidx.wear.tiles.proto.LayoutElementProto.Arc;
import androidx.wear.tiles.proto.LayoutElementProto.ArcAnchorTypeProp;
import androidx.wear.tiles.proto.LayoutElementProto.ArcLayoutElement;
import androidx.wear.tiles.proto.LayoutElementProto.ArcLine;
import androidx.wear.tiles.proto.LayoutElementProto.ArcSpacer;
import androidx.wear.tiles.proto.LayoutElementProto.ArcText;
import androidx.wear.tiles.proto.LayoutElementProto.Box;
import androidx.wear.tiles.proto.LayoutElementProto.Column;
import androidx.wear.tiles.proto.LayoutElementProto.ContentScaleMode;
import androidx.wear.tiles.proto.LayoutElementProto.FontStyle;
import androidx.wear.tiles.proto.LayoutElementProto.FontVariant;
import androidx.wear.tiles.proto.LayoutElementProto.HorizontalAlignmentProp;
import androidx.wear.tiles.proto.LayoutElementProto.Image;
import androidx.wear.tiles.proto.LayoutElementProto.Layout;
import androidx.wear.tiles.proto.LayoutElementProto.LayoutElement;
import androidx.wear.tiles.proto.LayoutElementProto.Row;
import androidx.wear.tiles.proto.LayoutElementProto.Spacer;
import androidx.wear.tiles.proto.LayoutElementProto.Span;
import androidx.wear.tiles.proto.LayoutElementProto.SpanImage;
import androidx.wear.tiles.proto.LayoutElementProto.SpanText;
import androidx.wear.tiles.proto.LayoutElementProto.SpanVerticalAlignmentProp;
import androidx.wear.tiles.proto.LayoutElementProto.Spannable;
import androidx.wear.tiles.proto.LayoutElementProto.Text;
import androidx.wear.tiles.proto.LayoutElementProto.TextAlignmentProp;
import androidx.wear.tiles.proto.LayoutElementProto.TextOverflowProp;
import androidx.wear.tiles.proto.LayoutElementProto.VerticalAlignmentProp;
import androidx.wear.tiles.proto.ModifiersProto.ArcModifiers;
import androidx.wear.tiles.proto.ModifiersProto.Background;
import androidx.wear.tiles.proto.ModifiersProto.Border;
import androidx.wear.tiles.proto.ModifiersProto.Clickable;
import androidx.wear.tiles.proto.ModifiersProto.Modifiers;
import androidx.wear.tiles.proto.ModifiersProto.Padding;
import androidx.wear.tiles.proto.ModifiersProto.SpanModifiers;
import androidx.wear.tiles.proto.StateProto.State;
import androidx.wear.tiles.renderer.R;
import androidx.wear.tiles.renderer.internal.ResourceResolvers.ResourceAccessException;
import androidx.wear.widget.ArcLayout;
import androidx.wear.widget.CurvedTextView;

import com.google.common.util.concurrent.ListenableFuture;

import java.lang.ref.WeakReference;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ExecutionException;
import java.util.concurrent.Executor;
import java.util.concurrent.Future;

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

    private static final String TAG = "TileRendererInternal";

    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;

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

    final Context mUiContext;
    private final Layout mLayoutProto;
    private final ResourceResolvers mResourceResolvers;

    private final FontSet mTitleFontSet;
    private final FontSet mBodyFontSet;

    final Executor mLoadActionExecutor;
    final LoadActionListener mLoadActionListener;

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

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

    /**
     * Default constructor.
     *
     * @param uiContext A {@link Context} suitable for interacting with the UI.
     * @param layout The portion of the Tile to render.
     * @param resourceResolvers Resolvers for the resources used for rendering this Prototile.
     * @param loadActionExecutor Executor to dispatch loadActionListener on.
     * @param loadActionListener Listener for clicks that will cause the contents to be reloaded.
     */
    public TileRendererInternal(
            @NonNull Context uiContext,
            @NonNull Layout layout,
            @NonNull ResourceResolvers resourceResolvers,
            @NonNull Executor loadActionExecutor,
            @NonNull LoadActionListener loadActionListener) {
        this(
                uiContext,
                layout,
                resourceResolvers,
                /* tilesTheme= */ 0,
                loadActionExecutor,
                loadActionListener);
    }

    /**
     * Default constructor.
     *
     * @param uiContext A {@link Context} suitable for interacting with the UI.
     * @param layout The portion of the Tile to render.
     * @param resourceResolvers Resolvers for the resources used for rendering this Prototile.
     * @param tilesTheme The theme to use for this Tiles instance. This can be used to customise
     *     things like the default font family. Pass 0 to use the default theme.
     * @param loadActionExecutor Executor to dispatch loadActionListener on.
     * @param loadActionListener Listener for clicks that will cause the contents to be reloaded.
     */
    public TileRendererInternal(
            @NonNull Context uiContext,
            @NonNull Layout layout,
            @NonNull ResourceResolvers resourceResolvers,
            @StyleRes int tilesTheme,
            @NonNull Executor loadActionExecutor,
            @NonNull LoadActionListener loadActionListener) {
        if (tilesTheme == 0) {
            tilesTheme = R.style.TilesBaseTheme;
        }

        TypedArray a = uiContext.obtainStyledAttributes(tilesTheme, R.styleable.TilesTheme);

        this.mTitleFontSet =
                new FontSet(uiContext, a.getResourceId(R.styleable.TilesTheme_tilesTitleFont, -1));
        this.mBodyFontSet =
                new FontSet(uiContext, a.getResourceId(R.styleable.TilesTheme_tilesBodyFont, -1));
        a.recycle();

        this.mUiContext = new ContextThemeWrapper(uiContext, tilesTheme);
        this.mLayoutProto = layout;
        this.mResourceResolvers = resourceResolvers;
        this.mLoadActionExecutor = loadActionExecutor;
        this.mLoadActionListener = loadActionListener;
    }

    private int safeDpToPx(DpProp dpProp) {
        return round(
                max(0, dpProp.getValue()) * mUiContext.getResources().getDisplayMetrics().density);
    }

    @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 ViewGroup.LayoutParams generateDefaultLayoutParams() {
        return new ViewGroup.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.WRAP_CONTENT);
    }

    private LayoutParams updateLayoutParamsInLinearLayout(
            LinearLayout parent,
            LayoutParams layoutParams,
            ContainerDimension width,
            ContainerDimension height) {
        // This is a little bit fun. Tiles' 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);

        // Handle the width
        if (parent.getOrientation() == LinearLayout.HORIZONTAL
                && width.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
            // If the parent container would not normally have "remaining space", ignore the
            // expand=true.
            if (parent.getLayoutParams().width == LayoutParams.WRAP_CONTENT) {
                linearLayoutParams.width = LayoutParams.WRAP_CONTENT;
            } else {
                linearLayoutParams.width = 0;
                linearLayoutParams.weight = 1;
            }
        } else {
            linearLayoutParams.width = dimensionToPx(width);
        }

        // And the height
        if (parent.getOrientation() == LinearLayout.VERTICAL
                && height.getInnerCase() == InnerCase.EXPANDED_DIMENSION) {
            // If the parent container would not normally have "remaining space", ignore the
            // expand=true.
            if (parent.getLayoutParams().height == LayoutParams.WRAP_CONTENT) {
                linearLayoutParams.height = LayoutParams.WRAP_CONTENT;
            } else {
                linearLayoutParams.height = 0;
                linearLayoutParams.weight = 1;
            }
        } else {
            linearLayoutParams.height = dimensionToPx(height);
        }

        return linearLayoutParams;
    }

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

        return layoutParams;
    }

    private static int horizontalAlignmentToGravity(HorizontalAlignmentProp alignment) {
        switch (alignment.getValue()) {
            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(VerticalAlignmentProp alignment) {
        switch (alignment.getValue()) {
            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) {
        // 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 TileRendererInternal 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) {
        FontSet fonts = mBodyFontSet;

        if (fontStyle.getVariant().getValue() == FontVariant.FONT_VARIANT_TITLE) {
            fonts = mTitleFontSet;
        }

        switch (fontStyle.getWeight().getValue()) {
            case FONT_WEIGHT_BOLD:
                return fonts.mBoldFont;
            case FONT_WEIGHT_MEDIUM:
                return fonts.mMediumFont;
            case FONT_WEIGHT_NORMAL:
            case FONT_WEIGHT_UNDEFINED:
            case UNRECOGNIZED:
                return fonts.mNormalFont;
        }

        return fonts.mNormalFont;
    }

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

    private static MetricAffectingSpan createTypefaceSpan(FontStyle fontStyle) {
        return new StyleSpan(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(
                TypedValue.COMPLEX_UNIT_SP,
                spField.getValue(),
                mUiContext.getResources().getDisplayMetrics());
    }

    private void applyFontStyle(FontStyle style, TextView textView) {
        // 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).
        textView.setTypeface(createTypeface(style), fontStyleToTypefaceStyle(style));

        if (style.hasSize()) {
            textView.setTextSize(TypedValue.COMPLEX_UNIT_SP, style.getSize().getValue());
        }

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

        textView.setTextColor(extractTextColorArgb(style));
    }

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

        // TODO(b/188801917): Implement underline. CurvedTextView (well, drawTextOnArc) doesn't
        // support underline. We can implement this later by drawing a line under the text ourselves
        // though.

        if (style.hasSize()) {
            textView.setTextSize(toPx(style.getSize()));
        }
    }

    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(View view, Clickable clickable) {
        view.setTag(clickable.getId());

        boolean hasAction = false;
        switch (clickable.getOnClick().getValueCase()) {
            case LAUNCH_ACTION:
                Intent i =
                        buildLaunchActionIntent(
                                clickable.getOnClick().getLaunchAction(), clickable.getId());
                if (i != null) {
                    hasAction = true;
                    view.setOnClickListener(
                            v -> {
                                i.setSourceBounds(getSourceBounds(view));
                                dispatchLaunchActionIntent(i);
                            });
                }
                break;
            case LOAD_ACTION:
                hasAction = true;
                view.setOnClickListener(
                        v ->
                                mLoadActionExecutor.execute(
                                        () ->
                                                mLoadActionListener.onClick(
                                                        buildState(
                                                                clickable
                                                                        .getOnClick()
                                                                        .getLoadAction(),
                                                                clickable.getId()))));
                break;
            case VALUE_NOT_SET:
                break;
        }

        if (hasAction) {
            // Apply ripple effect
            TypedValue outValue = new TypedValue();
            mUiContext
                    .getTheme()
                    .resolveAttribute(
                            android.R.attr.selectableItemBackground,
                            outValue,
                            /* resolveRefs= */ true);
            view.setForeground(mUiContext.getDrawable(outValue.resourceId));
        }
    }

    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) {
        if (drawable == null) {
            drawable = new GradientDrawable();
        }

        if (background.hasColor()) {
            drawable.setColor(background.getColor().getArgb());
        }

        if (background.hasCorner()) {
            drawable.setCornerRadius(safeDpToPx(background.getCorner().getRadius()));
            view.setClipToOutline(true);
            view.setOutlineProvider(ViewOutlineProvider.BACKGROUND);
        }

        return drawable;
    }

    private GradientDrawable applyBorder(Border border, @Nullable GradientDrawable drawable) {
        if (drawable == null) {
            drawable = new GradientDrawable();
        }

        drawable.setStroke(safeDpToPx(border.getWidth()), border.getColor().getArgb());

        return drawable;
    }

    private View applyModifiers(View view, Modifiers modifiers) {
        if (modifiers.hasClickable()) {
            applyClickable(view, modifiers.getClickable());
        }

        if (modifiers.hasSemantics()) {
            applyAudibleParams(view, modifiers.getSemantics().getContentDescription());
        }

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

        GradientDrawable backgroundDrawable = null;

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

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

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

        return view;
    }

    // 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) {
        if (!(view instanceof ArcLayout.Widget)) {
            Log.e(
                    TAG,
                    "applyModifiersToArcLayoutView should only be called with an ArcLayout.Widget");
            return view;
        }

        if (modifiers.hasClickable()) {
            applyClickable(view, modifiers.getClickable());
        }

        if (modifiers.hasSemantics()) {
            applyAudibleParams(view, modifiers.getSemantics().getContentDescription());
        }

        return view;
    }

    private static int textAlignToAndroidGravity(TextAlignmentProp alignment) {
        switch (alignment.getValue()) {
            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(TextOverflowProp type) {
        switch (type.getValue()) {
            case TEXT_OVERFLOW_TRUNCATE:
                // A null TruncateAt disables adding an ellipsis.
                return null;
            case TEXT_OVERFLOW_ELLIPSIZE_END:
                return TruncateAt.END;
            case TEXT_OVERFLOW_UNDEFINED:
            case UNRECOGNIZED:
                return TEXT_OVERFLOW_DEFAULT;
        }

        return TEXT_OVERFLOW_DEFAULT;
    }

    @ArcLayout.AnchorType
    private static int anchorTypeToAnchorPos(ArcAnchorTypeProp type) {
        switch (type.getValue()) {
            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;
    }

    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 tile
     * {@link LaunchAction}.
     */
    @Nullable
    public static Intent buildLaunchActionIntent(
            @NonNull LaunchAction launchAction, @NonNull String clickableId) {
        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()) {
                i.putExtra(TileService.EXTRA_CLICKABLE_ID, 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 View inflateColumn(ViewGroup parent, Column column) {
        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()));

        layoutParams = updateLayoutParams(parent, layoutParams, width, height);

        View wrappedView = applyModifiers(linearLayout, column.getModifiers());

        parent.addView(wrappedView, layoutParams);
        inflateLayoutElements(linearLayout, column.getContentsList());

        return wrappedView;
    }

    @Nullable
    private View inflateRow(ViewGroup parent, Row row) {
        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()));

        layoutParams = updateLayoutParams(parent, layoutParams, width, height);

        View wrappedView = applyModifiers(linearLayout, row.getModifiers());

        parent.addView(wrappedView, layoutParams);
        inflateLayoutElements(linearLayout, row.getContentsList());

        return wrappedView;
    }

    @Nullable
    private View inflateBox(ViewGroup parent, Box box) {
        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(parent, layoutParams, width, height);

        int gravity =
                horizontalAlignmentToGravity(box.getHorizontalAlignment())
                        | verticalAlignmentToGravity(box.getVerticalAlignment());

        View wrappedView = applyModifiers(frame, box.getModifiers());

        parent.addView(wrappedView, layoutParams);
        inflateLayoutElements(frame, box.getContentsList());

        // 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.
        applyGravityToFrameLayoutChildren(frame, gravity);

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

        return wrappedView;
    }

    @Nullable
    private View inflateSpacer(ViewGroup parent, Spacer spacer) {
        int widthPx = safeDpToPx(spacer.getWidth().getLinearDimension());
        int heightPx = safeDpToPx(spacer.getHeight().getLinearDimension());

        if (widthPx == 0 && heightPx == 0) {
            return null;
        }

        LayoutParams layoutParams = generateDefaultLayoutParams();

        // Modifiers cannot be applied to android's Space, so use a plain View if this Spacer has
        // modifiers.
        View view;
        if (spacer.hasModifiers()) {
            view = applyModifiers(new View(mUiContext), spacer.getModifiers());
            layoutParams =
                    updateLayoutParams(
                            parent,
                            layoutParams,
                            spacerDimensionToContainerDimension(spacer.getWidth()),
                            spacerDimensionToContainerDimension(spacer.getHeight()));
        } else {
            view = new Space(mUiContext);
            view.setMinimumWidth(widthPx);
            view.setMinimumHeight(heightPx);
        }

        parent.addView(view, layoutParams);

        return view;
    }

    @Nullable
    private View inflateArcSpacer(ViewGroup parent, ArcSpacer spacer) {
        float lengthDegrees = max(0, spacer.getLength().getValue());
        int thicknessPx = safeDpToPx(spacer.getThickness());

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

        WearCurvedSpacer space = new WearCurvedSpacer(mUiContext);

        LayoutParams layoutParams = generateDefaultLayoutParams();

        space.setSweepAngleDegrees(lengthDegrees);
        space.setThickness(thicknessPx);

        View wrappedView = applyModifiersToArcLayoutView(space, spacer.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

    private View inflateText(ViewGroup parent, Text text) {
        TextView textView =
                new TextView(mUiContext, /* attrs= */ null, R.attr.tilesFallbackTextAppearance);

        LayoutParams layoutParams = generateDefaultLayoutParams();

        // 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(text.getText().getValue());

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

        textView.setText(ssb);

        textView.setEllipsize(textTruncationToEllipsize(text.getOverflow()));
        textView.setGravity(textAlignToAndroidGravity(text.getMultilineAlignment()));

        if (text.hasMaxLines()) {
            textView.setMaxLines(max(TEXT_MIN_LINES, text.getMaxLines().getValue()));
        } else {
            textView.setMaxLines(TEXT_MAX_LINES_DEFAULT);
        }

        // Setting colours **must** go after setting the Text Appearance, otherwise it will get
        // immediately overridden.
        if (text.hasFontStyle()) {
            applyFontStyle(text.getFontStyle(), textView);
        } else {
            applyFontStyle(FontStyle.getDefaultInstance(), 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 Semantics
        // 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);

        View wrappedView = applyModifiers(textView, text.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

    private View inflateArcText(ViewGroup parent, ArcText text) {
        CurvedTextView textView =
                new CurvedTextView(
                        mUiContext, /* attrs= */ null, R.attr.tilesFallbackTextAppearance);

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

        textView.setTextColor(extractTextColorArgb(text.getFontStyle()));

        View wrappedView = applyModifiersToArcLayoutView(textView, text.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

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

    private static ContainerDimension spacerDimensionToContainerDimension(
            SpacerDimension dimension) {
        switch (dimension.getInnerCase()) {
            case LINEAR_DIMENSION:
                return ContainerDimension.newBuilder()
                        .setLinearDimension(dimension.getLinearDimension())
                        .build();
            case INNER_NOT_SET:
                // A spacer is allowed to have missing dimension and this should be considered as
                // 0dp.
                return ContainerDimension.newBuilder()
                        .setLinearDimension(DpProp.getDefaultInstance())
                        .build();
        }
        // Caller should have already checked for this.
        throw new IllegalArgumentException(
                "SpacerDimension has an unknown dimension type: "
                        + dimension.getInnerCase().name());
    }

    @SuppressWarnings("ExecutorTaskName")
    @Nullable
    private View inflateImage(ViewGroup parent, Image image) {
        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(
                        parent,
                        ratioWrapperLayoutParams,
                        imageDimensionToContainerDimension(image.getWidth()),
                        imageDimensionToContainerDimension(image.getHeight()));

        RatioViewWrapper ratioViewWrapper = new RatioViewWrapper(mUiContext);
        ratioViewWrapper.setAspectRatio(ratio);
        ratioViewWrapper.addView(imageView);

        // Finally, wrap the image in any modifiers...
        View wrappedView = applyModifiers(ratioViewWrapper, image.getModifiers());

        parent.addView(wrappedView, ratioWrapperLayoutParams);

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

        if (!isImageSet) {
            // Is there a placeholder to use in the meantime?
            try {
                if (mResourceResolvers.hasPlaceholderDrawable(protoResId)) {
                    imageView.setImageDrawable(
                            mResourceResolvers.getPlaceholderDrawableOrThrow(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 = mResourceResolvers.canImageBeTinted(protoResId);
        } catch (IllegalArgumentException ex) {
            // This implies that the image doesn't exist, but in that case, the above statement
            // should have thrown.
            Log.wtf(TAG, "Exception tinting image " + protoResId, ex);
        }

        if (image.hasFilter()) {
            if (image.getFilter().hasTint() && canImageBeTinted) {
                // Only allow tinting for Android images.
                ColorStateList tint = ColorStateList.valueOf(image.getFilter().getTint().getArgb());
                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);
            }
        }

        return wrappedView;
    }

    private static boolean setImageDrawable(
            ImageView imageView, Future<Drawable> drawableFuture, String protoResId) {
        try {
            imageView.setImageDrawable(drawableFuture.get());
            return true;
        } catch (ExecutionException | InterruptedException e) {
            Log.w(TAG, "Could not get drawable for image " + protoResId);
        }
        return false;
    }

    @Nullable
    private View inflateArcLine(ViewGroup parent, ArcLine line) {
        float lengthDegrees = max(0, line.getLength().getValue());
        int thicknessPx = safeDpToPx(line.getThickness());

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

        WearCurvedLineView lineView = new WearCurvedLineView(mUiContext);

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

        int lineColor = LINE_COLOR_DEFAULT;
        if (line.hasColor()) {
            lineColor = line.getColor().getArgb();
        }

        lineView.setThickness(thicknessPx);
        lineView.setSweepAngleDegrees(lengthDegrees);
        lineView.setColor(lineColor);

        View wrappedView = applyModifiersToArcLayoutView(lineView, line.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

    @Nullable
    private View inflateArc(ViewGroup parent, Arc arc) {
        ArcLayout arcLayout = new ArcLayout(mUiContext);

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

        arcLayout.setAnchorAngleDegrees(arc.getAnchorAngle().getValue());
        arcLayout.setAnchorType(anchorTypeToAnchorPos(arc.getAnchorType()));

        // Add all children.
        for (ArcLayoutElement child : arc.getContentsList()) {
            @Nullable View childView = inflateArcLayoutElement(arcLayout, child);
            if (childView != null) {
                ArcLayout.LayoutParams childLayoutParams =
                        (ArcLayout.LayoutParams) childView.getLayoutParams();
                boolean rotate = false;
                if (child.hasAdapter()) {
                    rotate = child.getAdapter().getRotateContents().getValue();
                }

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

        View wrappedView = applyModifiers(arcLayout, arc.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

    private void applyStylesToSpan(
            SpannableStringBuilder builder, int start, int end, FontStyle fontStyle) {
        if (fontStyle.hasSize()) {
            AbsoluteSizeSpan span = new AbsoluteSizeSpan(round(toPx(fontStyle.getSize())));
            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)) {
            MetricAffectingSpan span = createTypefaceSpan(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;

        colorSpan = new ForegroundColorSpan(extractTextColorArgb(fontStyle));

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

    private void applyModifiersToSpan(
            SpannableStringBuilder builder, int start, int end, SpanModifiers modifiers) {
        if (modifiers.hasClickable()) {
            ClickableSpan clickableSpan = new TilesClickableSpan(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 = mResourceResolvers.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 (mResourceResolvers.hasPlaceholderDrawable(protoResId)) {
                    placeholderDrawable =
                            mResourceResolvers.getPlaceholderDrawableOrThrow(protoResId);
                }
            } catch (ResourceAccessException 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();

        // Note, we need to replace a real character, not a space. Spaces at the end of a line are
        // trimmed, which means that some image spans can be removed from the Span if they end up
        // being located at the end of a line.
        builder.append("A", 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 e) {
            Log.w(TAG, "Could not get drawable for image " + protoResourceId);
        }
    }

    private View inflateSpannable(ViewGroup parent, Spannable spannable) {
        TextView tv =
                new TextView(mUiContext, /* attrs= */ null, R.attr.tilesFallbackTextAppearance);

        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;
                default:
                    Log.w(TAG, "Unknown Span child type.");
                    break;
            }
        }

        tv.setEllipsize(textTruncationToEllipsize(spannable.getOverflow()));
        tv.setGravity(horizontalAlignmentToGravity(spannable.getMultilineAlignment()));

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

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

        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, spannable.getModifiers());
        parent.addView(wrappedView, layoutParams);

        return wrappedView;
    }

    @Nullable
    private View inflateArcLayoutElement(ViewGroup parent, ArcLayoutElement element) {
        View inflatedView = null;

        switch (element.getInnerCase()) {
            case ADAPTER:
                // Fall back to the normal inflater.
                inflatedView = inflateLayoutElement(parent, element.getAdapter().getContent());
                break;

            case SPACER:
                inflatedView = inflateArcSpacer(parent, element.getSpacer());
                break;

            case LINE:
                inflatedView = inflateArcLine(parent, element.getLine());
                break;

            case TEXT:
                inflatedView = inflateArcText(parent, element.getText());
                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");
        }

        return inflatedView;
    }

    @Nullable
    private View inflateLayoutElement(ViewGroup parent, LayoutElement element) {
        // What is it?
        View inflatedView = null;
        switch (element.getInnerCase()) {
            case COLUMN:
                inflatedView = inflateColumn(parent, element.getColumn());
                break;
            case ROW:
                inflatedView = inflateRow(parent, element.getRow());
                break;
            case BOX:
                inflatedView = inflateBox(parent, element.getBox());
                break;
            case SPACER:
                inflatedView = inflateSpacer(parent, element.getSpacer());
                break;
            case TEXT:
                inflatedView = inflateText(parent, element.getText());
                break;
            case IMAGE:
                inflatedView = inflateImage(parent, element.getImage());
                break;
            case ARC:
                inflatedView = inflateArc(parent, element.getArc());
                break;
            case SPANNABLE:
                inflatedView = inflateSpannable(parent, element.getSpannable());
                break;
            case INNER_NOT_SET:
            default: // TODO(b/178359365): Remove default case
                Log.w(TAG, "Unknown child type: " + element.getInnerCase().name());
                break;
        }

        return inflatedView;
    }

    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;
        }
        if (containerHeight.hasWrappedDimension()
                && !containsMeasurableHeight(containerWidth, elements)) {
            return false;
        }
        return true;
    }

    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 INNER_NOT_SET:
            default: // TODO(b/178359365): Remove default case
                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 INNER_NOT_SET:
            default: // TODO(b/178359365): Remove default case
                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 INNER_NOT_SET:
                return false;
        }
        return false;
    }

    private void inflateLayoutElements(ViewGroup parent, List<LayoutElement> elements) {
        for (LayoutElement element : elements) {
            inflateLayoutElement(parent, element);
        }
    }

    /**
     * Inflates a Tile into {@code parent}.
     *
     * @param parent The view to attach the tile into.
     * @return The first child that was inflated. 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 View inflate(@NonNull ViewGroup parent) {
        // Go!
        return inflateLayoutElement(parent, mLayoutProto.getRoot());
    }

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

    private static void applyAudibleParams(View view, String accessibilityLabel) {
        view.setContentDescription(accessibilityLabel);
        view.setFocusable(true);
        view.setImportantForAccessibility(View.IMPORTANT_FOR_ACCESSIBILITY_YES);
    }

    /** Implementation of ClickableSpan for Tiles' Clickables. */
    private class TilesClickableSpan extends ClickableSpan {
        private final Clickable mClickable;

        TilesClickableSpan(@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());
                    if (i != null) {
                        dispatchLaunchActionIntent(i);
                    }
                    break;
                case LOAD_ACTION:
                    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.
        }
    }

    // Android's normal ImageSpan (well, DynamicDrawableSpan) applies baseline alignment incorrectly
    // in some cases. It incorrectly assumes that the difference between the bottom (as passed to
    // draw) and baseline of the text is always equal to the font descent, when that doesn't always
    // hold. Instead, the "y" parameter is the Y coordinate of the baseline, so base the baseline
    // alignment on that rather than "bottom".
    private static class FixedImageSpan extends ImageSpan {
        private WeakReference<Drawable> mDrawableRef;

        FixedImageSpan(@NonNull Drawable drawable) {
            super(drawable);
        }

        FixedImageSpan(@NonNull Drawable drawable, int verticalAlignment) {
            super(drawable, verticalAlignment);
        }

        @Override
        public void draw(
                @androidx.annotation.NonNull Canvas canvas,
                CharSequence text,
                int start,
                int end,
                float x,
                int top,
                int y,
                int bottom,
                @androidx.annotation.NonNull Paint paint) {
            Drawable b = getCachedDrawable();
            canvas.save();

            int transY = bottom - b.getBounds().bottom;
            if (mVerticalAlignment == ALIGN_BASELINE) {
                transY = y - b.getBounds().bottom;
            } else if (mVerticalAlignment == ALIGN_CENTER) {
                transY = (bottom - top) / 2 - b.getBounds().height() / 2;
            }

            canvas.translate(x, transY);
            b.draw(canvas);
            canvas.restore();
        }

        private Drawable getCachedDrawable() {
            WeakReference<Drawable> wr = mDrawableRef;
            Drawable d = null;

            if (wr != null) {
                d = wr.get();
            }

            if (d == null) {
                d = getDrawable();
                mDrawableRef = new WeakReference<>(d);
            }

            return d;
        }
    }

    /** Holder for different weights of the same font variant. */
    private static class FontSet {
        final Typeface mNormalFont;
        final Typeface mMediumFont;
        final Typeface mBoldFont;

        FontSet(@NonNull Context appContext, int style) {
            TypedArray a = appContext.obtainStyledAttributes(style, R.styleable.TilesFontSet);

            this.mNormalFont = loadTypeface(a, R.styleable.TilesFontSet_tilesNormalFont);
            this.mMediumFont = loadTypeface(a, R.styleable.TilesFontSet_tilesMediumFont);
            this.mBoldFont = loadTypeface(a, R.styleable.TilesFontSet_tilesBoldFont);

            a.recycle();
        }

        @SuppressLint("RestrictedApi") // TODO(b/183006740): Remove when prefix check is fixed.
        private static Typeface loadTypeface(TypedArray array, int styleableResId) {
            // Resources are a little nasty; we can't just check if resType =
            // TypedValue.TYPE_REFERENCE, because it never is (if you use @font/foo inside of
            // styles.xml, the value will be a string of the form res/font/foo.ttf). Instead, see if
            // there's a resource ID at all, and use that, otherwise assume it's a well known font
            // family.
            int resType = array.getType(styleableResId);

            if (array.getResourceId(styleableResId, -1) != -1
                    && array.getFont(styleableResId) != null) {
                return checkNotNull(array.getFont(styleableResId));
            } else if (resType == TypedValue.TYPE_STRING
                    && array.getString(styleableResId) != null) {
                // Load the normal typeface; we customise this into BOLD/ITALIC later on.
                return Typeface.create(
                        checkNotNull(array.getString(styleableResId)), Typeface.NORMAL);
            } else {
                throw new IllegalArgumentException("Unknown resource value type " + resType);
            }
        }
    }

    /** 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) {}
    }
}