chromium/chrome/browser/ui/android/omnibox/java/src/org/chromium/chrome/browser/omnibox/suggestions/answer/RichAnswerText.java

// Copyright 2024 The Chromium Authors
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.

package org.chromium.chrome.browser.omnibox.suggestions.answer;

import android.content.Context;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.style.MetricAffectingSpan;

import androidx.annotation.NonNull;
import androidx.annotation.StyleRes;
import androidx.annotation.VisibleForTesting;

import org.chromium.components.omnibox.AnswerDataProto.FormattedString;
import org.chromium.components.omnibox.AnswerDataProto.FormattedString.ColorType;
import org.chromium.components.omnibox.AnswerDataProto.FormattedString.FormattedStringFragment;
import org.chromium.components.omnibox.AnswerTypeProto.AnswerType;
import org.chromium.components.omnibox.OmniboxFeatures;
import org.chromium.components.omnibox.RichAnswerTemplateProto.RichAnswerTemplate;
import org.chromium.ui.text.DownloadableFontTextAppearanceSpan;

import java.util.List;

/**
 * {@link AnswerText} implementation based on RichAnswerTemplate as the source of answer lines (as
 * opposed to SuggestionAnswer, implemented by {@link AnswerTextNewLayout}).
 */
class RichAnswerText implements AnswerText {

    /** Content of the line of text in omnibox suggestion. */
    private final SpannableStringBuilder mText;

    private final Context mContext;
    private final boolean mIsAnswerLine;
    private final boolean mReverseStockTextColor;
    private String mAccessibilityDescription;
    private int mMaxLines = 1;
    private final AnswerType mAnswerType;
    private boolean mUseRichAnswerCard;

    @Override
    public SpannableStringBuilder getText() {
        return mText;
    }

    @Override
    public String getAccessibilityDescription() {
        return mAccessibilityDescription;
    }

    @Override
    public int getMaxLines() {
        return mMaxLines;
    }

    /** Construct an array of two AnswerText instances for the given RichAnswerTemplate. */
    static AnswerText[] from(
            @NonNull Context context,
            @NonNull RichAnswerTemplate richAnswerTemplate,
            AnswerType answerType,
            boolean reverseStockTextColor,
            boolean useRichAnswerCard) {
        RichAnswerText[] result = new RichAnswerText[2];

        int maxLines = getMaxLinesForAnswerType(answerType);
        if (answerType == AnswerType.ANSWER_TYPE_DICTIONARY) {
            result[0] =
                    new RichAnswerText(
                            context,
                            richAnswerTemplate.getAnswers(0).getHeadline(),
                            answerType,
                            /* isAnswerLine= */ true,
                            reverseStockTextColor,
                            useRichAnswerCard);
            result[1] =
                    new RichAnswerText(
                            context,
                            richAnswerTemplate.getAnswers(0).getSubhead(),
                            answerType,
                            /* isAnswerLine= */ false,
                            reverseStockTextColor,
                            useRichAnswerCard);
            result[1].mMaxLines = maxLines;
            return result;
        }

        FormattedString firstLine;
        FormattedString secondLine;
        if (shouldSkipTextReversal(answerType)) {
            firstLine = richAnswerTemplate.getAnswers(0).getHeadline();
            secondLine = richAnswerTemplate.getAnswers(0).getSubhead();
        } else {
            firstLine = richAnswerTemplate.getAnswers(0).getSubhead();
            secondLine = richAnswerTemplate.getAnswers(0).getHeadline();
        }

        // Construct the Answer card presenting Answers in Suggest in Answer > Query order.
        result[0] =
                new RichAnswerText(
                        context,
                        firstLine,
                        answerType,
                        /* isAnswerLine= */ true,
                        reverseStockTextColor,
                        useRichAnswerCard);
        result[1] =
                new RichAnswerText(
                        context,
                        secondLine,
                        answerType,
                        /* isAnswerLine= */ false,
                        reverseStockTextColor,
                        useRichAnswerCard);
        result[0].mMaxLines = maxLines;

        // Note: Despite Answers in Suggest being presented in reverse order (first answer, then
        // query) we want to ensure that the query is announced first to visually impaired
        // people to avoid confusion, so we swap a11y texts.
        String temp = result[1].mAccessibilityDescription;
        result[1].mAccessibilityDescription = result[0].mAccessibilityDescription;
        result[0].mAccessibilityDescription = temp;
        return result;
    }

    private RichAnswerText(
            Context context,
            FormattedString formattedString,
            AnswerType answerType,
            boolean isAnswerLine,
            boolean reverseStockTextColor,
            boolean useRichAnswerCard) {
        mContext = context;
        mAnswerType = answerType;
        mIsAnswerLine = isAnswerLine;
        mReverseStockTextColor = reverseStockTextColor;
        mUseRichAnswerCard = useRichAnswerCard;
        mText = processFormattedString(formattedString);
        mAccessibilityDescription = mText.toString();
    }

    private SpannableStringBuilder processFormattedString(FormattedString formattedString) {
        SpannableStringBuilder result = new SpannableStringBuilder();
        List<FormattedStringFragment> fragments = formattedString.getFragmentsList();
        if (fragments.size() > 0) {
            for (int i = 0; i < fragments.size(); i++) {
                FormattedStringFragment formattedStringFragment = fragments.get(i);
                String text =
                        AnswerTextUtils.processAnswerText(
                                formattedStringFragment.getText(), mIsAnswerLine, mAnswerType);
                appendAndStyleText(
                        text, getAppearanceForText(formattedStringFragment.getColor()), result);
            }
        } else {
            String text =
                    AnswerTextUtils.processAnswerText(
                            formattedString.getText(), mIsAnswerLine, mAnswerType);
            appendAndStyleText(
                    text, getAppearanceForText(ColorType.COLOR_ON_SURFACE_DEFAULT), result);
        }

        return result;
    }

    private void appendAndStyleText(
            String text, MetricAffectingSpan style, SpannableStringBuilder result) {
        if (!result.toString().isEmpty()) {
            result.append(" ");
        }

        int startIndex = result.length();
        result.append(text);
        result.setSpan(style, startIndex, result.length(), Spannable.SPAN_EXCLUSIVE_EXCLUSIVE);
    }

    /**
     * Return the TextAppearanceSpan specifying text decorations for a given fragment.
     *
     * @return MetricAffectingSpan specifying style to be used to present text field.
     */
    private MetricAffectingSpan getAppearanceForText(ColorType colorType) {
        return mIsAnswerLine
                ? getAppearanceForAnswerText(
                        mContext,
                        colorType,
                        mAnswerType,
                        mReverseStockTextColor,
                        mUseRichAnswerCard)
                : getAppearanceForQueryText();
    }

    /**
     * Return text style for elements in main line holding answer.
     *
     * @param colorType The color type for the text.
     * @param context Current context.
     * @return TextAppearanceSpan object defining style for the text.
     */
    @VisibleForTesting
    static MetricAffectingSpan getAppearanceForAnswerText(
            Context context,
            ColorType colorType,
            AnswerType answerType,
            boolean reverseStockTextColor,
            boolean useRichAnswerCard) {
        @StyleRes
        int largeRes =
                useRichAnswerCard
                        ? org.chromium.chrome.browser.omnibox.R.style
                                .TextAppearance_Headline2Thick_Primary
                        : org.chromium.chrome.browser.omnibox.R.style
                                .TextAppearance_TextLarge_Primary;
        if (answerType != AnswerType.ANSWER_TYPE_DICTIONARY
                && answerType != AnswerType.ANSWER_TYPE_FINANCE) {
            return new DownloadableFontTextAppearanceSpan(context, largeRes);
        }

        // TODO(b/327497146): skip color reversal when original data source is proto backend, which
        // should handle color reversal server side.
        return switch (colorType) {
            case COLOR_ON_SURFACE_POSITIVE, COLOR_ON_SURFACE_NEGATIVE -> {
                boolean wantPositiveColor = (colorType == ColorType.COLOR_ON_SURFACE_POSITIVE);
                // Swap positive/negative colors if applicable for current locale.
                wantPositiveColor ^= reverseStockTextColor;
                int styleResource =
                        wantPositiveColor
                                ? org.chromium.chrome.browser.omnibox.R.style
                                        .TextAppearance_OmniboxAnswerDescriptionPositive
                                : org.chromium.chrome.browser.omnibox.R.style
                                        .TextAppearance_OmniboxAnswerDescriptionNegative;
                yield new DownloadableFontTextAppearanceSpan(context, styleResource);
            }
            default -> new DownloadableFontTextAppearanceSpan(context, largeRes);
                // TODO(b/327497146): handle equivalent of
                // AnswerTextType.SUGGESTION_SECONDARY_TEXT_MEDIUM
        };
    }

    /**
     * Return text style for elements in second line holding query.
     *
     * @return MetricAffectingSpan object defining style for the text.
     */
    private MetricAffectingSpan getAppearanceForQueryText() {
        @StyleRes
        int res =
                mUseRichAnswerCard
                        ? org.chromium.chrome.browser.omnibox.R.style
                                .TextAppearance_TextLarge_Secondary
                        : org.chromium.chrome.browser.omnibox.R.style
                                .TextAppearance_TextMedium_Secondary;
        return new DownloadableFontTextAppearanceSpan(mContext, res);
    }

    private static int getMaxLinesForAnswerType(AnswerType answerType) {
        return (answerType == AnswerType.ANSWER_TYPE_DICTIONARY
                        || answerType == AnswerType.ANSWER_TYPE_TRANSLATION)
                ? 3
                : 1;
    }

    /**
     * When shouldShowAnswerActions() is true, the backend provides dictionary, finance, sports,
     * knowledge graph, and weather answer in reversed form already. Dictionary is handled
     * separately, but for the remainder we want to skip reversing the text but continue to swap
     * a11y content.
     */
    private static boolean shouldSkipTextReversal(AnswerType answerType) {
        return OmniboxFeatures.shouldShowAnswerActions()
                && (answerType == AnswerType.ANSWER_TYPE_FINANCE
                        || answerType == AnswerType.ANSWER_TYPE_SPORTS
                        || answerType == AnswerType.ANSWER_TYPE_WEATHER);
    }
}