CustomSpanBundler.java

/*
 * Copyright 2023 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
 *
 *      https://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.media3.common.text;

import static androidx.media3.common.util.Assertions.checkNotNull;
import static java.lang.annotation.ElementType.TYPE_USE;

import android.os.Bundle;
import android.text.Spannable;
import android.text.Spanned;
import android.text.style.BackgroundColorSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.UnderlineSpan;
import androidx.annotation.IntDef;
import androidx.annotation.Nullable;
import androidx.media3.common.util.Util;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
import java.util.ArrayList;

/**
 * Provides serialization support for custom Media3 styling spans.
 *
 * <p>Custom Media3 spans are not serialized by {@link Bundle#putCharSequence}, unlike
 * platform-provided spans such as {@link StrikethroughSpan}, {@link UnderlineSpan}, {@link
 * BackgroundColorSpan} etc.
 *
 * <p>{@link Cue#text} might contain custom spans, there is a need for serialization support.
 */
/* package */ final class CustomSpanBundler {

  /**
   * Media3 custom span implementations. One of the following:
   *
   * <ul>
   *   <li>{@link #UNKNOWN}
   *   <li>{@link #RUBY}
   *   <li>{@link #TEXT_EMPHASIS}
   *   <li>{@link #HORIZONTAL_TEXT_IN_VERTICAL_CONTEXT}
   * </ul>
   */
  @Documented
  @Retention(RetentionPolicy.SOURCE)
  @Target({TYPE_USE})
  @IntDef({UNKNOWN, RUBY, TEXT_EMPHASIS, HORIZONTAL_TEXT_IN_VERTICAL_CONTEXT})
  private @interface CustomSpanType {}

  private static final int UNKNOWN = -1;

  private static final int RUBY = 1;

  private static final int TEXT_EMPHASIS = 2;

  private static final int HORIZONTAL_TEXT_IN_VERTICAL_CONTEXT = 3;

  private static final String FIELD_START_INDEX = Util.intToStringMaxRadix(0);
  private static final String FIELD_END_INDEX = Util.intToStringMaxRadix(1);
  private static final String FIELD_FLAGS = Util.intToStringMaxRadix(2);
  private static final String FIELD_TYPE = Util.intToStringMaxRadix(3);
  private static final String FIELD_PARAMS = Util.intToStringMaxRadix(4);

  @SuppressWarnings("NonApiType") // Intentionally using ArrayList for putParcelableArrayList.
  public static ArrayList<Bundle> bundleCustomSpans(Spanned text) {
    ArrayList<Bundle> bundledCustomSpans = new ArrayList<>();
    for (RubySpan span : text.getSpans(0, text.length(), RubySpan.class)) {
      Bundle bundle = spanToBundle(text, span, /* spanType= */ RUBY, /* params= */ span.toBundle());
      bundledCustomSpans.add(bundle);
    }
    for (TextEmphasisSpan span : text.getSpans(0, text.length(), TextEmphasisSpan.class)) {
      Bundle bundle =
          spanToBundle(text, span, /* spanType= */ TEXT_EMPHASIS, /* params= */ span.toBundle());
      bundledCustomSpans.add(bundle);
    }
    for (HorizontalTextInVerticalContextSpan span :
        text.getSpans(0, text.length(), HorizontalTextInVerticalContextSpan.class)) {
      Bundle bundle =
          spanToBundle(
              text, span, /* spanType= */ HORIZONTAL_TEXT_IN_VERTICAL_CONTEXT, /* params= */ null);
      bundledCustomSpans.add(bundle);
    }
    return bundledCustomSpans;
  }

  public static void unbundleAndApplyCustomSpan(Bundle customSpanBundle, Spannable text) {
    int start = customSpanBundle.getInt(FIELD_START_INDEX);
    int end = customSpanBundle.getInt(FIELD_END_INDEX);
    int flags = customSpanBundle.getInt(FIELD_FLAGS);
    int customSpanType = customSpanBundle.getInt(FIELD_TYPE, UNKNOWN);
    @Nullable Bundle span = customSpanBundle.getBundle(FIELD_PARAMS);
    switch (customSpanType) {
      case RUBY:
        text.setSpan(RubySpan.fromBundle(checkNotNull(span)), start, end, flags);
        break;
      case TEXT_EMPHASIS:
        text.setSpan(TextEmphasisSpan.fromBundle(checkNotNull(span)), start, end, flags);
        break;
      case HORIZONTAL_TEXT_IN_VERTICAL_CONTEXT:
        text.setSpan(new HorizontalTextInVerticalContextSpan(), start, end, flags);
        break;
      default:
        break;
    }
  }

  private static Bundle spanToBundle(
      Spanned spanned, Object span, @CustomSpanType int spanType, @Nullable Bundle params) {
    Bundle bundle = new Bundle();
    bundle.putInt(FIELD_START_INDEX, spanned.getSpanStart(span));
    bundle.putInt(FIELD_END_INDEX, spanned.getSpanEnd(span));
    bundle.putInt(FIELD_FLAGS, spanned.getSpanFlags(span));
    bundle.putInt(FIELD_TYPE, spanType);
    if (params != null) {
      bundle.putBundle(FIELD_PARAMS, params);
    }
    return bundle;
  }

  private CustomSpanBundler() {}
}