/*
* Copyright 2020 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.car.app.model;
import static androidx.annotation.RestrictTo.Scope.LIBRARY;
import static java.util.Objects.requireNonNull;
import android.text.SpannableString;
import android.text.Spanned;
import androidx.annotation.Keep;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.RestrictTo;
import androidx.car.app.annotations.CarProtocol;
import androidx.car.app.annotations.RequiresCarApi;
import androidx.car.app.utils.CollectionUtils;
import androidx.car.app.utils.StringUtils;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;
/**
* A model that represents text to display in the car screen.
*
* <h2>Text handling in the library</h2>
*
* Models that consume text strings take a {@link CharSequence} type as the parameter type. These
* strings can contain spans that are applied to the text and allow, for example, changing the
* color of the text, introducing inline images, or displaying a time duration.
* As described in
* <a href="https://developer.android.com/guide/topics/text/spans">the span documentation</a>,
* you can use types such as {@link SpannableString} or {@link android.text.SpannedString} to
* create the strings with the spans
*
* <h4>Text spans in strings</h4>
*
* <p>The Car App Library only supports a specific set of spans of type {@link CarSpan}. Further,
* individual APIs in the library that take text as input may only support a certain subset of
* {@link CarSpan}s. Spans that are not supported will be simply ignored by the host.
*
* <p>By default and unless explicitly documented in the individual APIs that take a text
* parameter as input, spans for that API are not supported and will be ignored.
*
* <p>For example, the {@link Row.Builder#addText(CharSequence)} API documents that
* {@link ForegroundCarColorSpan} instances can be used to color the text of the row. This means any
* other types of spans except {@link ForegroundCarColorSpan} will be ignored.
*
* <p>{@link CarText} instances represent the text that was passed by the app through a
* {@link CharSequence}, with the non-{@link CarSpan} spans removed.
*
* <p>The {@link CarText#toString} method can be used to get a string representation of the string,
* whereas the {@link CarText#toCharSequence()} method returns the reconstructed
* {@link CharSequence}, with the non{@link CarSpan} spans removed.
*
* <h4>Text variants of multiple lengths</h4>
*
* <p>The app is generally agnostic to the width of the views generated by the host that contain
* the text strings it supplies. For that reason, some models that take text allow the app to
* pass a list of text variants of different lengths. In those cases the host will pick the
* variant that best fits the screen. See {@link Builder#addVariant} for more information.
*/
@CarProtocol
public final class CarText {
@Keep
private final String mText;
@Keep
private final List<String> mTextVariants;
@Keep
private final List<SpanWrapper> mSpans;
@Keep
private final List<List<SpanWrapper>> mSpansForVariants;
/**
* Returns {@code true} if the {@code carText} is {@code null} or an empty string, {@code
* false} otherwise.
*/
public static boolean isNullOrEmpty(@Nullable CarText carText) {
return carText == null || carText.isEmpty();
}
/**
* Returns a {@link CarText} instance for the given {@link CharSequence}.
*
* <p>Only {@link CarSpan} type spans are allowed in a {@link CarText}, other spans will be
* removed from the provided {@link CharSequence}.
*
* @throws NullPointerException if the text is {@code null}
*/
@NonNull
public static CarText create(@NonNull CharSequence text) {
return new CarText(requireNonNull(text));
}
/**
* Returns whether the text string is empty.
*
* <p>Only the first variant is checked.
*/
public boolean isEmpty() {
return mText.isEmpty();
}
/**
* Returns the string representation of the {@link CarText}.
*
* <p>Only the first variant is returned.
*/
@NonNull
@Override
public String toString() {
return mText;
}
/**
* Returns the {@link CharSequence} corresponding to the first text variant.
*
* <p>Spans that are not of type {@link CarSpan} that were passed when creating the
* {@link CarText} instance will not be present in the returned {@link CharSequence}.
*
* @see CarText#create(CharSequence)
*/
@NonNull
public CharSequence toCharSequence() {
return getCharSequence(mText, mSpans);
}
/**
* Returns the list of variants for this text.
*
* <p>Only the variants set with {@link Builder#addVariant(CharSequence)} will be returned.
* To get the first variant, use {@link CarText#toCharSequence}.
*
* <p>Spans that are not of type {@link CarSpan} that were passed when creating the
* {@link CarText} instance will not be present in the returned {@link CharSequence}.
*
* @see Builder#addVariant(CharSequence)
*/
@NonNull
public List<CharSequence> getVariants() {
if (mTextVariants.isEmpty()) {
return Collections.emptyList();
}
List<CharSequence> charSequences = new ArrayList<>();
for (int i = 0; i < mTextVariants.size(); i++) {
charSequences.add(getCharSequence(mTextVariants.get(i), mSpansForVariants.get(i)));
}
return Collections.unmodifiableList(charSequences);
}
/**
* @hide
*/
@NonNull
@RestrictTo(LIBRARY)
public List<SpanWrapper> getSpans() {
return mSpans;
}
/**
* @hide
*/
@NonNull
@RestrictTo(LIBRARY)
public List<List<SpanWrapper>> getSpansForVariants() {
return mSpansForVariants;
}
/**
* Returns a shortened string from the input {@code text}.
*
* @hide
*/
@RestrictTo(LIBRARY)
@Nullable
public static String toShortString(@Nullable CarText text) {
return text == null ? null : StringUtils.shortenString(text.toString());
}
private CarText() {
mText = "";
mSpans = Collections.emptyList();
mTextVariants = Collections.emptyList();
mSpansForVariants = Collections.emptyList();
}
CarText(CharSequence text) {
mText = text.toString();
mSpans = getSpans(text);
mTextVariants = Collections.emptyList();
mSpansForVariants = Collections.emptyList();
}
CarText(Builder builder) {
mText = builder.mText.toString();
mSpans = getSpans(builder.mText);
List<CharSequence> textVariants = builder.mTextVariants;
List<String> textList = new ArrayList<>();
List<List<SpanWrapper>> spanList = new ArrayList<>();
for (int i = 0; i < textVariants.size(); i++) {
CharSequence text = textVariants.get(i);
textList.add(text.toString());
spanList.add(getSpans(text));
}
mTextVariants = CollectionUtils.unmodifiableCopy(textList);
mSpansForVariants = CollectionUtils.unmodifiableCopy(spanList);
}
private static List<SpanWrapper> getSpans(CharSequence text) {
List<SpanWrapper> spans = new ArrayList<>();
if (text instanceof Spanned) {
Spanned spanned = (Spanned) text;
for (Object span : spanned.getSpans(0, text.length(), Object.class)) {
if (span instanceof CarSpan) {
spans.add(new SpanWrapper(spanned, (CarSpan) span));
}
}
}
return CollectionUtils.unmodifiableCopy(spans);
}
private static CharSequence getCharSequence(String text, List<SpanWrapper> spans) {
SpannableString spannableString = new SpannableString(text);
for (SpanWrapper spanWrapper : CollectionUtils.emptyIfNull(spans)) {
spannableString.setSpan(
spanWrapper.getCarSpan(),
spanWrapper.getStart(),
spanWrapper.getEnd(),
spanWrapper.getFlags());
}
return spannableString;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof CarText)) {
return false;
}
CarText otherText = (CarText) other;
return Objects.equals(mText, otherText.mText)
&& Objects.equals(mSpans, otherText.mSpans)
&& Objects.equals(mTextVariants, otherText.mTextVariants)
&& Objects.equals(mSpansForVariants, otherText.mSpansForVariants);
}
@Override
public int hashCode() {
return Objects.hash(mText, mSpans, mTextVariants, mSpansForVariants);
}
/**
* Wraps a span to send it to the host.
*
* @hide
*/
@RestrictTo(LIBRARY)
public static class SpanWrapper {
@Keep
private final int mStart;
@Keep
private final int mEnd;
@Keep
private final int mFlags;
@Keep
@NonNull
private final CarSpan mCarSpan;
SpanWrapper(@NonNull Spanned spanned, @NonNull CarSpan carSpan) {
mStart = spanned.getSpanStart(carSpan);
mEnd = spanned.getSpanEnd(carSpan);
mFlags = spanned.getSpanFlags(carSpan);
mCarSpan = carSpan;
}
SpanWrapper() {
mStart = 0;
mEnd = 0;
mFlags = 0;
mCarSpan = new CarSpan();
}
public int getStart() {
return mStart;
}
public int getEnd() {
return mEnd;
}
public int getFlags() {
return mFlags;
}
@NonNull
public CarSpan getCarSpan() {
return mCarSpan;
}
@Override
public boolean equals(@Nullable Object other) {
if (this == other) {
return true;
}
if (!(other instanceof SpanWrapper)) {
return false;
}
SpanWrapper wrapper = (SpanWrapper) other;
return mStart == wrapper.mStart
&& mEnd == wrapper.mEnd
&& mFlags == wrapper.mFlags
&& Objects.equals(mCarSpan, wrapper.mCarSpan);
}
@Override
public int hashCode() {
return Objects.hash(mStart, mEnd, mFlags, mCarSpan);
}
@NonNull
@Override
public String toString() {
return "[" + mCarSpan + ": " + mStart + ", " + mEnd + ", flags: " + mFlags + "]";
}
}
/** A builder of {@link CarText}. */
public static final class Builder {
@Keep
CharSequence mText;
@Keep
List<CharSequence> mTextVariants = new ArrayList<>();
/**
* Returns a new instance of a {@link Builder}.
*
* <p>Only {@link CarSpan} type spans are allowed in a {@link CarText}, other spans will be
* removed from the provided {@link CharSequence}.
*
* @param text the first variant of the text to use. This represents the app's preferred
* text variant. Other alternatives can be supplied with
* {@link Builder#addVariant}.
* @throws NullPointerException if the text is {@code null}
* @see Builder#addVariant(CharSequence)
*/
public Builder(@NonNull CharSequence text) {
mText = requireNonNull(text);
}
/**
* Adds a text variant for the {@link CarText} instance.
*
* <p>Only {@link CarSpan} type spans are allowed in a {@link CarText}, other spans will be
* removed from the provided {@link CharSequence}.
*
* <p>The text variants should be added in order of preference, from most to least
* preferred (for instance, from longest to shortest). If the text provided via
* {@link #Builder} does not fit in the screen, the host will display the
* first variant that fits in the screen.
*
* <p>For instance, if the variant order is ["long string", "shorter", "short"], and the
* screen can fit 7 characters, "shorter" will be chosen. However, if the order is
* ["short", "shorter", "long string"], "short" will be chosen, because "short" fits
* within the 7 character limit.
*
* @throws NullPointerException if the text is {@code null}
*/
@RequiresCarApi(2)
@NonNull
public Builder addVariant(@NonNull CharSequence text) {
mTextVariants.add(requireNonNull(text));
return this;
}
/**
* Constructs the {@link CarText} defined by this builder.
*/
@NonNull
public CarText build() {
return new CarText(this);
}
}
}