public final class

TileRendererInternal

extends java.lang.Object

 java.lang.Object

↳androidx.wear.tiles.renderer.internal.TileRendererInternal

Overview

Renderer for Tiles.

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

Summary

Constructors
publicTileRendererInternal(Context uiContext, Layout layout, ResourceResolvers resourceResolvers, java.util.concurrent.Executor loadActionExecutor, TileRendererInternal.LoadActionListener loadActionListener)

Default constructor.

publicTileRendererInternal(Context uiContext, Layout layout, ResourceResolvers resourceResolvers, int tilesTheme, java.util.concurrent.Executor loadActionExecutor, TileRendererInternal.LoadActionListener loadActionListener)

Default constructor.

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

Returns an Android that can perform the action defined in the given tile .

public Viewinflate(ViewGroup parent)

Inflates a Tile into parent.

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

Constructors

public TileRendererInternal(Context uiContext, Layout layout, ResourceResolvers resourceResolvers, java.util.concurrent.Executor loadActionExecutor, TileRendererInternal.LoadActionListener loadActionListener)

Default constructor.

Parameters:

uiContext: A suitable for interacting with the UI.
layout: The portion of the Tile to render.
resourceResolvers: Resolvers for the resources used for rendering this Prototile.
loadActionExecutor: Executor to dispatch loadActionListener on.
loadActionListener: Listener for clicks that will cause the contents to be reloaded.

public TileRendererInternal(Context uiContext, Layout layout, ResourceResolvers resourceResolvers, int tilesTheme, java.util.concurrent.Executor loadActionExecutor, TileRendererInternal.LoadActionListener loadActionListener)

Default constructor.

Parameters:

uiContext: A suitable for interacting with the UI.
layout: The portion of the Tile to render.
resourceResolvers: Resolvers for the resources used for rendering this Prototile.
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.
loadActionExecutor: Executor to dispatch loadActionListener on.
loadActionListener: Listener for clicks that will cause the contents to be reloaded.

Methods

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

Returns an Android that can perform the action defined in the given tile .

public View inflate(ViewGroup parent)

Inflates a Tile into parent.

Parameters:

parent: The view to attach the tile into.

Returns:

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.

Source

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