PrecomputedTextCompat.java

/*
 * Copyright 2018 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.core.text;

import static androidx.annotation.RestrictTo.Scope.LIBRARY_GROUP_PREFIX;

import android.annotation.SuppressLint;
import android.os.Build;
import android.text.Layout;
import android.text.PrecomputedText;
import android.text.Spannable;
import android.text.SpannableString;
import android.text.StaticLayout;
import android.text.TextDirectionHeuristic;
import android.text.TextDirectionHeuristics;
import android.text.TextPaint;
import android.text.TextUtils;
import android.text.style.MetricAffectingSpan;

import androidx.annotation.GuardedBy;
import androidx.annotation.IntRange;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RequiresApi;
import androidx.annotation.RestrictTo;
import androidx.annotation.UiThread;
import androidx.core.os.TraceCompat;
import androidx.core.util.ObjectsCompat;
import androidx.core.util.Preconditions;

import java.util.ArrayList;
import java.util.concurrent.Callable;
import java.util.concurrent.Executor;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.FutureTask;

/**
 * A text which has the character metrics data.
 *
 * A text object that contains the character metrics data and can be used to improve the performance
 * of text layout operations. When a PrecomputedTextCompat is created with a given
 * {@link CharSequence}, it will measure the text metrics during the creation. This PrecomputedText
 * instance can be set on {@link android.widget.TextView} or {@link StaticLayout}. Since the text
 * layout information will be included in this instance, {@link android.widget.TextView} or
 * {@link StaticLayout} will not have to recalculate this information.
 *
 * On API 29 or later, there is full PrecomputedText support by framework. From API 21 to API 27,
 * PrecomputedTextCompat relies on internal text layout cache. PrecomputedTextCompat immediately
 * computes the text layout in the constuctor to warm up the internal text layout cache. On API 20
 * or before, PrecomputedTextCompat does nothing.
 *
 * Note that any {@link android.text.NoCopySpan} attached to the original text won't be passed to
 * PrecomputedText.
 */
public class PrecomputedTextCompat implements Spannable {
    private static final char LINE_FEED = '\n';

    private static final Object sLock = new Object();
    @GuardedBy("sLock") private static @NonNull Executor sExecutor = null;

    /**
     * The information required for building {@link PrecomputedTextCompat}.
     *
     * Contains information required for precomputing text measurement metadata, so it can be done
     * in isolation of a {@link android.widget.TextView} or {@link StaticLayout}, when final layout
     * constraints are not known.
     */
    public static final class Params {
        private final @NonNull TextPaint mPaint;

        // null on API 17 or before, non null on API 18 or later.
        private final @Nullable TextDirectionHeuristic mTextDir;

        private final int mBreakStrategy;

        private final int mHyphenationFrequency;

        final PrecomputedText.Params mWrapped;

        /**
         * A builder for creating {@link Params}.
         */
        public static class Builder {
            // The TextPaint used for measurement.
            private final @NonNull TextPaint mPaint;

            // The requested text direction.
            private TextDirectionHeuristic mTextDir;

            // The break strategy for this measured text.
            private int mBreakStrategy;

            // The hyphenation frequency for this measured text.
            private int mHyphenationFrequency;

            /**
             * Builder constructor.
             *
             * @param paint the paint to be used for drawing
             */
            public Builder(@NonNull TextPaint paint) {
                mPaint = paint;
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                    mBreakStrategy = Layout.BREAK_STRATEGY_HIGH_QUALITY;
                    mHyphenationFrequency = Layout.HYPHENATION_FREQUENCY_NORMAL;
                } else {
                    mBreakStrategy = mHyphenationFrequency = 0;
                }
                if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
                    mTextDir = TextDirectionHeuristics.FIRSTSTRONG_LTR;
                } else {
                    mTextDir = null;
                }
            }

            /**
             * Set the line break strategy.
             *
             * The default value is {@link Layout#BREAK_STRATEGY_HIGH_QUALITY}.
             *
             * On API 22 and below, this has no effect as there is no line break strategy.
             *
             * @param strategy the break strategy
             * @return PrecomputedTextCompat.Builder instance
             * @see StaticLayout.Builder#setBreakStrategy
             * @see android.widget.TextView#setBreakStrategy
             */
            @RequiresApi(23)
            public Builder setBreakStrategy(int strategy) {
                mBreakStrategy = strategy;
                return this;
            }

            /**
             * Set the hyphenation frequency.
             *
             * The default value is {@link Layout#HYPHENATION_FREQUENCY_NORMAL}.
             *
             * On API 22 and below, this has no effect as there is no hyphenation frequency.
             *
             * @param frequency the hyphenation frequency
             * @return PrecomputedTextCompat.Builder instance
             * @see StaticLayout.Builder#setHyphenationFrequency
             * @see android.widget.TextView#setHyphenationFrequency
             */
            @RequiresApi(23)
            public Builder setHyphenationFrequency(int frequency) {
                mHyphenationFrequency = frequency;
                return this;
            }

            /**
             * Set the text direction heuristic.
             *
             * The default value is {@link TextDirectionHeuristics#FIRSTSTRONG_LTR}.
             *
             * On API 17 or before, text direction heuristics cannot be modified, so this method
             * does nothing.
             *
             * @param textDir the text direction heuristic for resolving bidi behavior
             * @return PrecomputedTextCompat.Builder instance
             * @see StaticLayout.Builder#setTextDirection
             */
            @RequiresApi(18)
            public Builder setTextDirection(@NonNull TextDirectionHeuristic textDir) {
                mTextDir = textDir;
                return this;
            }

            /**
             * Build the {@link Params}.
             *
             * @return the layout parameter
             */
            public @NonNull Params build() {
                return new Params(mPaint, mTextDir, mBreakStrategy, mHyphenationFrequency);
            }
        }

        @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
        Params(@NonNull TextPaint paint, @NonNull TextDirectionHeuristic textDir,
                int strategy, int frequency) {
            if (Build.VERSION.SDK_INT >= 29) {
                mWrapped = new PrecomputedText.Params.Builder(paint)
                        .setBreakStrategy(strategy)
                        .setHyphenationFrequency(frequency)
                        .setTextDirection(textDir)
                        .build();
            } else {
                mWrapped = null;
            }
            mPaint = paint;
            mTextDir = textDir;
            mBreakStrategy = strategy;
            mHyphenationFrequency = frequency;
        }

        @RequiresApi(28)
        public Params(@NonNull PrecomputedText.Params wrapped) {
            mPaint = wrapped.getTextPaint();
            mTextDir = wrapped.getTextDirection();
            mBreakStrategy = wrapped.getBreakStrategy();
            mHyphenationFrequency = wrapped.getHyphenationFrequency();
            mWrapped = (Build.VERSION.SDK_INT >= 29) ? wrapped : null;
        }

        /**
         * Returns the {@link TextPaint} for this text.
         *
         * @return A {@link TextPaint}
         */
        public @NonNull TextPaint getTextPaint() {
            return mPaint;
        }

        /**
         * Returns the {@link TextDirectionHeuristic} for this text.
         *
         * On API 17 and below, this returns null, otherwise returns non-null
         * TextDirectionHeuristic.
         *
         * @return the {@link TextDirectionHeuristic}
         */
        @RequiresApi(18)
        public @Nullable TextDirectionHeuristic getTextDirection() {
            return mTextDir;
        }

        /**
         * Returns the break strategy for this text.
         *
         * On API 22 and below, this returns 0.
         *
         * @return the line break strategy
         */
        @RequiresApi(23)
        public int getBreakStrategy() {
            return mBreakStrategy;
        }

        /**
         * Returns the hyphenation frequency for this text.
         *
         * On API 22 and below, this returns 0.
         *
         * @return the hyphenation frequency
         */
        @RequiresApi(23)
        public int getHyphenationFrequency() {
            return mHyphenationFrequency;
        }


        /**
         * Similar to equals but don't compare text direction
         * @hide
         */
        @RestrictTo(LIBRARY_GROUP_PREFIX)
        public boolean equalsWithoutTextDirection(@NonNull Params other) {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                if (mBreakStrategy != other.getBreakStrategy()) {
                    return false;
                }
                if (mHyphenationFrequency != other.getHyphenationFrequency()) {
                    return false;
                }
            }

            if (mPaint.getTextSize() != other.getTextPaint().getTextSize()) {
                return false;
            }
            if (mPaint.getTextScaleX() != other.getTextPaint().getTextScaleX()) {
                return false;
            }
            if (mPaint.getTextSkewX() != other.getTextPaint().getTextSkewX()) {
                return false;
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                if (mPaint.getLetterSpacing() != other.getTextPaint().getLetterSpacing()) {
                    return false;
                }
                if (!TextUtils.equals(mPaint.getFontFeatureSettings(),
                        other.getTextPaint().getFontFeatureSettings())) {
                    return false;
                }
            }
            if (mPaint.getFlags() != other.getTextPaint().getFlags()) {
                return false;
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                if (!mPaint.getTextLocales().equals(other.getTextPaint().getTextLocales())) {
                    return false;
                }
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                if (!mPaint.getTextLocale().equals(other.getTextPaint().getTextLocale())) {
                    return false;
                }
            }
            if (mPaint.getTypeface() == null) {
                if (other.getTextPaint().getTypeface() != null) {
                    return false;
                }
            } else if (!mPaint.getTypeface().equals(other.getTextPaint().getTypeface())) {
                return false;
            }

            return true;
        }

        /**
         * Check if the same text layout.
         *
         * @return true if this and the given param result in the same text layout
         */
        @Override
        public boolean equals(@Nullable Object o) {
            if (o == this) {
                return true;
            }
            if (!(o instanceof Params)) {
                return false;
            }
            Params other = (Params) o;
            if (!equalsWithoutTextDirection(other)) {
                return false;
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
                if (mTextDir != other.getTextDirection()) {
                    return false;
                }
            }
            return true;
        }

        @Override
        public int hashCode() {
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                return ObjectsCompat.hash(mPaint.getTextSize(), mPaint.getTextScaleX(),
                        mPaint.getTextSkewX(), mPaint.getLetterSpacing(), mPaint.getFlags(),
                        mPaint.getTextLocales(), mPaint.getTypeface(), mPaint.isElegantTextHeight(),
                        mTextDir, mBreakStrategy, mHyphenationFrequency);
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                return ObjectsCompat.hash(mPaint.getTextSize(), mPaint.getTextScaleX(),
                        mPaint.getTextSkewX(), mPaint.getLetterSpacing(), mPaint.getFlags(),
                        mPaint.getTextLocale(), mPaint.getTypeface(), mPaint.isElegantTextHeight(),
                        mTextDir, mBreakStrategy, mHyphenationFrequency);
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR2) {
                return ObjectsCompat.hash(mPaint.getTextSize(), mPaint.getTextScaleX(),
                        mPaint.getTextSkewX(), mPaint.getFlags(), mPaint.getTextLocale(),
                        mPaint.getTypeface(), mTextDir, mBreakStrategy, mHyphenationFrequency);
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                return ObjectsCompat.hash(mPaint.getTextSize(), mPaint.getTextScaleX(),
                        mPaint.getTextSkewX(), mPaint.getFlags(), mPaint.getTextLocale(),
                        mPaint.getTypeface(), mTextDir, mBreakStrategy, mHyphenationFrequency);
            } else {
                return ObjectsCompat.hash(mPaint.getTextSize(), mPaint.getTextScaleX(),
                        mPaint.getTextSkewX(), mPaint.getFlags(), mPaint.getTypeface(), mTextDir,
                        mBreakStrategy, mHyphenationFrequency);
            }
        }

        @Override
        public String toString() {
            StringBuilder sb = new StringBuilder("{");
            sb.append("textSize=" + mPaint.getTextSize());
            sb.append(", textScaleX=" + mPaint.getTextScaleX());
            sb.append(", textSkewX=" + mPaint.getTextSkewX());
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                sb.append(", letterSpacing=" + mPaint.getLetterSpacing());
                sb.append(", elegantTextHeight=" + mPaint.isElegantTextHeight());
            }
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
                sb.append(", textLocale=" + mPaint.getTextLocales());
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
                sb.append(", textLocale=" + mPaint.getTextLocale());
            }
            sb.append(", typeface=" + mPaint.getTypeface());
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
                sb.append(", variationSettings=" + mPaint.getFontVariationSettings());
            }
            sb.append(", textDir=" + mTextDir);
            sb.append(", breakStrategy=" + mBreakStrategy);
            sb.append(", hyphenationFrequency=" + mHyphenationFrequency);
            sb.append("}");
            return sb.toString();
        }
    };

    // The original text.
    private final @NonNull Spannable mText;

    private final @NonNull Params mParams;

    // The list of measured paragraph info.
    private final @NonNull int[] mParagraphEnds;

    // null on API 27 or before. Non-null on API 29 or later
    private final @Nullable PrecomputedText mWrapped;

    /**
     * Create a new {@link PrecomputedText} which will pre-compute text measurement and glyph
     * positioning information.
     * <p>
     * This can be expensive, so computing this on a background thread before your text will be
     * presented can save work on the UI thread.
     * </p>
     *
     * Note that any {@link android.text.NoCopySpan} attached to the text won't be passed to the
     * created PrecomputedText.
     *
     * @param text the text to be measured
     * @param params parameters that define how text will be precomputed
     * @return A {@link PrecomputedText}
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    public static PrecomputedTextCompat create(@NonNull CharSequence text, @NonNull Params params) {
        Preconditions.checkNotNull(text);
        Preconditions.checkNotNull(params);

        try {
            TraceCompat.beginSection("PrecomputedText");

            if (Build.VERSION.SDK_INT >= 29 && params.mWrapped != null) {
                return new PrecomputedTextCompat(
                        PrecomputedText.create(text, params.mWrapped), params);
            }

            ArrayList<Integer> ends = new ArrayList<>();

            int paraEnd = 0;
            int end = text.length();
            for (int paraStart = 0; paraStart < end; paraStart = paraEnd) {
                paraEnd = TextUtils.indexOf(text, LINE_FEED, paraStart, end);
                if (paraEnd < 0) {
                    // No LINE_FEED(U+000A) character found. Use end of the text as the paragraph
                    // end.
                    paraEnd = end;
                } else {
                    paraEnd++;  // Includes LINE_FEED(U+000A) to the prev paragraph.
                }

                ends.add(paraEnd);
            }
            int[] result = new int[ends.size()];
            for (int i = 0; i < ends.size(); ++i) {
                result[i] = ends.get(i);
            }

            // No framework support for PrecomputedText
            // Compute text layout and throw away StaticLayout for the purpose of warming up the
            // internal text layout cache.
            if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
                StaticLayout.Builder.obtain(text, 0, text.length(), params.getTextPaint(),
                        Integer.MAX_VALUE)
                        .setBreakStrategy(params.getBreakStrategy())
                        .setHyphenationFrequency(params.getHyphenationFrequency())
                        .setTextDirection(params.getTextDirection())
                        .build();
            } else if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
                new StaticLayout(text, params.getTextPaint(), Integer.MAX_VALUE,
                        Layout.Alignment.ALIGN_NORMAL, 1.0f, 0.0f, false);
            } else {
                // There is no way of precomputing text layout on API 20 or before
                // Do nothing
            }

            return new PrecomputedTextCompat(text, params, result);
        } finally {
            TraceCompat.endSection();
        }
    }

    // Use PrecomputedText.create instead.
    private PrecomputedTextCompat(@NonNull CharSequence text, @NonNull Params params,
            @NonNull int[] paraEnds) {
        mText = new SpannableString(text);
        mParams = params;
        mParagraphEnds = paraEnds;
        mWrapped = null;
    }

    @RequiresApi(28)
    private PrecomputedTextCompat(@NonNull PrecomputedText precomputed, @NonNull Params params) {
        mText = precomputed;
        mParams = params;
        mParagraphEnds = null;
        mWrapped = (Build.VERSION.SDK_INT >= 29) ? precomputed : null;
    }

    /**
     * Returns the underlying original text if the text is PrecomputedText.
     * @hide
     */
    @RestrictTo(LIBRARY_GROUP_PREFIX)
    @RequiresApi(28)
    public @Nullable PrecomputedText getPrecomputedText() {
        if (mText instanceof PrecomputedText) {
            return (PrecomputedText) mText;
        } else {
            return null;
        }
    }

    /**
     * Returns the parameters used to measure this text.
     */
    public @NonNull Params getParams() {
        return mParams;
    }

    /**
     * Returns the count of paragraphs.
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    public @IntRange(from = 0) int getParagraphCount() {
        if (Build.VERSION.SDK_INT >= 29) {
            return mWrapped.getParagraphCount();
        } else {
            return mParagraphEnds.length;
        }
    }

    /**
     * Returns the paragraph start offset of the text.
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    public @IntRange(from = 0) int getParagraphStart(@IntRange(from = 0) int paraIndex) {
        Preconditions.checkArgumentInRange(paraIndex, 0, getParagraphCount(), "paraIndex");
        if (Build.VERSION.SDK_INT >= 29) {
            return mWrapped.getParagraphStart(paraIndex);
        } else {
            return paraIndex == 0 ? 0 : mParagraphEnds[paraIndex - 1];
        }
    }

    /**
     * Returns the paragraph end offset of the text.
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    public @IntRange(from = 0) int getParagraphEnd(@IntRange(from = 0) int paraIndex) {
        Preconditions.checkArgumentInRange(paraIndex, 0, getParagraphCount(), "paraIndex");
        if (Build.VERSION.SDK_INT >= 29) {
            return mWrapped.getParagraphEnd(paraIndex);
        } else {
            return mParagraphEnds[paraIndex];
        }
    }

    /**
     * A helper class for computing text layout in background
     */
    private static class PrecomputedTextFutureTask extends FutureTask<PrecomputedTextCompat> {
        private static class PrecomputedTextCallback implements Callable<PrecomputedTextCompat> {
            private PrecomputedTextCompat.Params mParams;
            private CharSequence mText;

            PrecomputedTextCallback(@NonNull final PrecomputedTextCompat.Params params,
                    @NonNull final CharSequence cs) {
                mParams = params;
                mText = cs;
            }

            @Override
            public PrecomputedTextCompat call() throws Exception {
                return PrecomputedTextCompat.create(mText, mParams);
            }
        }

        PrecomputedTextFutureTask(@NonNull final PrecomputedTextCompat.Params params,
                @NonNull final CharSequence text) {
            super(new PrecomputedTextCallback(params, text));
        }
    }

    /**
     * Helper for PrecomputedText that returns a future to be used with
     * {@link androidx.appcompat.widget.AppCompatTextView#setTextFuture}.
     *
     * PrecomputedText is suited to compute on a background thread, but when TextView properties are
     * dynamic, it's common to configure text properties and text at the same time, when binding a
     * View. For example, in a RecyclerView Adapter:
     * <pre>
     *     void onBindViewHolder(ViewHolder vh, int position) {
     *         ItemData data = getData(position);
     *
     *         vh.textView.setTextSize(...);
     *         vh.textView.setFontVariationSettings(...);
     *         vh.textView.setText(data.text);
     *     }
     * </pre>
     * In such cases, using PrecomputedText is difficult, since it isn't safe to defer the setText()
     * code arbitrarily - a layout pass may happen before computation finishes, and will be
     * incorrect if the text isn't ready yet.
     * <p>
     * With {@code getTextFuture()}, you can block on the result of the precomputation safely
     * before the result is needed. AppCompatTextView provides
     * {@link androidx.appcompat.widget.AppCompatTextView#setTextFuture} for exactly this
     * use case. With the following code, the app's layout work is largely done on a background
     * thread:
     * <pre>
     *     void onBindViewHolder(ViewHolder vh, int position) {
     *         ItemData data = getData(position);
     *
     *         vh.textView.setTextSize(...);
     *         vh.textView.setFontVariationSettings(...);
     *
     *         // start precompute
     *         Future<PrecomputedTextCompat> future = PrecomputedTextCompat.getTextFuture(
     *                 data.text, vh.textView.getTextMetricsParamsCompat(), myExecutor);
     *
     *         // and pass future to TextView, which awaits result before measuring
     *         vh.textView.setTextFuture(future);
     *     }
     * </pre>
     * Because RecyclerView
     * {@link androidx.recyclerview.widget.RecyclerView.LayoutManager#isItemPrefetchEnabled
     * prefetches} bind multiple frames in advance while scrolling, the text work generally has
     * plenty of time to complete before measurement occurs.
     * </p>
     * <p class="note">
     *     <strong>Note:</strong> all TextView layout properties must be set before creating the
     *     Params object. If they are changed during the precomputation, this can cause a
     *     {@link IllegalArgumentException} when the precomputed value is consumed during measure,
     *     and doesn't reflect the TextView's current state.
     * </p>
     * @param charSequence the text to be displayed
     * @param params the parameters to be used for displaying text
     * @param executor the executor to be process the text layout. If null is passed, the default
     *                single threaded pool will be used.
     * @return a future of the precomputed text
     *
     * @see androidx.appcompat.widget.AppCompatTextView#setTextFuture
     */
    @UiThread
    public static Future<PrecomputedTextCompat> getTextFuture(
            @NonNull final CharSequence charSequence, @NonNull PrecomputedTextCompat.Params params,
            @Nullable Executor executor) {
        PrecomputedTextFutureTask task = new PrecomputedTextFutureTask(params, charSequence);
        if (executor == null) {
            synchronized (sLock) {
                if (sExecutor == null) {
                    sExecutor = Executors.newFixedThreadPool(1);
                }
                executor = sExecutor;
            }
        }
        executor.execute(task);
        return task;
    }


    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Spannable overrides
    //
    // Do not allow to modify MetricAffectingSpan

    /**
     * @throws IllegalArgumentException if {@link MetricAffectingSpan} is specified.
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    @Override
    public void setSpan(Object what, int start, int end, int flags) {
        if (what instanceof MetricAffectingSpan) {
            throw new IllegalArgumentException(
                    "MetricAffectingSpan can not be set to PrecomputedText.");
        }
        if (Build.VERSION.SDK_INT >= 29) {
            mWrapped.setSpan(what, start, end, flags);
        } else {
            mText.setSpan(what, start, end, flags);
        }
    }

    /**
     * @throws IllegalArgumentException if {@link MetricAffectingSpan} is specified.
     */
    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    @Override
    public void removeSpan(Object what) {
        if (what instanceof MetricAffectingSpan) {
            throw new IllegalArgumentException(
                    "MetricAffectingSpan can not be removed from PrecomputedText.");
        }
        if (Build.VERSION.SDK_INT >= 29) {
            mWrapped.removeSpan(what);
        } else {
            mText.removeSpan(what);
        }
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // Spanned overrides
    //
    // Just proxy for underlying mText if appropriate.

    @SuppressLint("NewApi")  // TODO: Remove once Q SDK is released
    @Override
    public <T> T[] getSpans(int start, int end, Class<T> type) {
        if (Build.VERSION.SDK_INT >= 29) {
            return mWrapped.getSpans(start, end, type);
        } else {
            return mText.getSpans(start, end, type);
        }

    }

    @Override
    public int getSpanStart(Object tag) {
        return mText.getSpanStart(tag);
    }

    @Override
    public int getSpanEnd(Object tag) {
        return mText.getSpanEnd(tag);
    }

    @Override
    public int getSpanFlags(Object tag) {
        return mText.getSpanFlags(tag);
    }

    @Override
    public int nextSpanTransition(int start, int limit, Class type) {
        return mText.nextSpanTransition(start, limit, type);
    }

    ///////////////////////////////////////////////////////////////////////////////////////////////
    // CharSequence overrides.
    //
    // Just proxy for underlying mText.

    @Override
    public int length() {
        return mText.length();
    }

    @Override
    public char charAt(int index) {
        return mText.charAt(index);
    }

    @Override
    public CharSequence subSequence(int start, int end) {
        return mText.subSequence(start, end);
    }

    @NonNull
    @Override
    public String toString() {
        return mText.toString();
    }
}