diff --git a/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java b/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java
new file mode 100644
index 00000000000..f530a2f4b8d
--- /dev/null
+++ b/libraries/ui/src/main/java/androidx/media3/ui/BidiUtils.java
@@ -0,0 +1,171 @@
+/*
+ * Copyright (C) 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.media3.ui;
+
+import android.text.BidiFormatter;
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.TextDirectionHeuristics;
+import androidx.annotation.Nullable;
+import androidx.media3.common.util.Log;
+import com.google.common.base.Joiner;
+import com.google.common.base.Splitter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+
+/**
+ * Utility class for handling bidirectional (BiDi) text rendering.
+ *
+ * This class provides methods to check for right-to-left (RTL) characters in a text and to wrap
+ * text lines for proper BiDi rendering using {@link BidiFormatter}.
+ */
+final class BidiUtils {
+
+ private static final String TAG = "BidiUtils";
+
+ /**
+ * Checks whether the given {@link CharSequence} contains any characters
+ * with right-to-left (RTL) directionality.
+ *
+ * This method inspects each character's Unicode directionality and returns {@code true}
+ * if at least one character is classified as RTL. This includes characters with the following
+ * directionalities:
+ *
+ * - {@link Character#DIRECTIONALITY_RIGHT_TO_LEFT}
+ * - {@link Character#DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC}
+ * - {@link Character#DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING}
+ * - {@link Character#DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE}
+ *
+ *
+ * @param input the input {@link CharSequence} to analyze
+ * @return {@code true} if the input contains at least one RTL character; {@code false} otherwise
+ */
+ static boolean containsRTL(@Nullable CharSequence input) {
+ if (input == null) {
+ return false;
+ }
+ int length = input.length();
+ for (int offset = 0; offset < length; ) {
+ int codePoint = Character.codePointAt(input, offset);
+ byte dir = Character.getDirectionality(codePoint);
+ if (dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT ||
+ dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_ARABIC ||
+ dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_EMBEDDING ||
+ dir == Character.DIRECTIONALITY_RIGHT_TO_LEFT_OVERRIDE) {
+ return true;
+ }
+ offset += Character.charCount(codePoint);
+ }
+ return false;
+ }
+
+ /**
+ * Applies bidirectional (BiDi) Unicode wrapping to each line of the given {@link CharSequence}.
+ *
+ * This method ensures that text containing both left-to-right (LTR) and right-to-left (RTL)
+ * scripts is displayed correctly by wrapping each line using {@link BidiFormatter#unicodeWrap}.
+ * It forces LTR context for wrapping and preserves spans and line breaks.
+ *
+ * @param input the input text as a {@link CharSequence}, possibly containing mixed-direction text
+ * @return a {@link CharSequence} with each line wrapped for proper bidi rendering
+ */
+ public static CharSequence wrapText(CharSequence input) {
+ BidiFormatter bidiFormatter = BidiFormatter.getInstance();
+ Spanned spannedInput = null;
+ Object[] spans = null;
+ int[] spanStarts = null;
+ int[] spanEnds = null;
+
+
+ if (input instanceof Spanned) {
+ // Preserve span in the input text.
+ spannedInput = (Spanned) input;
+ spans = spannedInput.getSpans(0, input.length(), Object.class);
+ // Create arrays to track the start and end of each span after wrapping.
+ spanStarts = new int[spans.length];
+ spanEnds = new int[spans.length];
+ Arrays.fill(spanStarts, -1);
+ Arrays.fill(spanEnds, -1);
+ }
+
+ // Determine the eol sequence for splitting the input text.
+ String inputStr = input.toString();
+ String eol = "\n";
+ if (inputStr.contains("\r\n")) {
+ eol = "\r\n";
+ }
+ Iterable lines = Splitter.on(eol).split(inputStr);
+
+ List wrappedLines = new ArrayList<>();
+
+ // Calculate the offset of each span after wrapping
+ int spanUpdate = 0;
+ int lineStart = 0;
+ for (String line : lines) {
+ // According to unicodeWrap documentation, this will either add 2 more characters or none
+ String wrappedLine = bidiFormatter.unicodeWrap(line, TextDirectionHeuristics.LTR, true);
+ if (spans != null) {
+ int diff = wrappedLine.length() - line.length();
+ if (diff > 0) {
+ spanUpdate++;
+ }
+ for (int j = 0; j < spans.length; j++) {
+ // Each span start or end is updated only once
+ if ((spanStarts[j] < 0) &&
+ (spannedInput.getSpanStart(spans[j]) >= lineStart) &&
+ (spannedInput.getSpanStart(spans[j]) < lineStart + line.length())) {
+ spanStarts[j] = spanUpdate;
+ }
+ if ((spanEnds[j] < 0) &&
+ ((spannedInput.getSpanEnd(spans[j]) - 1) >= lineStart) &&
+ ((spannedInput.getSpanEnd(spans[j]) - 1) < lineStart + line.length())) {
+ spanEnds[j] = spanUpdate;
+ }
+ }
+ lineStart += line.length() + eol.length();
+ if (diff > 0) {
+ spanUpdate++;
+ }
+ }
+ wrappedLines.add(wrappedLine);
+ }
+
+ // Create a new SpannableStringBuilder with the wrapped lines.
+ Joiner joiner = Joiner.on("\n");
+ SpannableStringBuilder wrapped = new SpannableStringBuilder(joiner.join(wrappedLines));
+
+ if (spans != null) {
+ // Reapply original spans to the wrapped lines.
+ for (int i = 0; i < spans.length; i++) {
+ int start = spannedInput.getSpanStart(spans[i]) + spanStarts[i];
+ int end = spannedInput.getSpanEnd(spans[i]) + spanEnds[i];
+ int flags = spannedInput.getSpanFlags(spans[i]);
+ if ((start >= 0) && (start < wrapped.length())
+ && (end >= 0) && (end <= wrapped.length())) {
+ // Only set the span if the start and end are within bounds of the wrapped text.
+ wrapped.setSpan(spans[i], start, end, flags);
+ } else {
+ Log.w(TAG,
+ "Span out of bounds: start=" + start + ",end=" + end + ",len=" + wrapped.length());
+ }
+ }
+ }
+
+ return wrapped;
+ }
+}
diff --git a/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java b/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java
index 4d298da946d..6c54289f6a6 100644
--- a/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java
+++ b/libraries/ui/src/main/java/androidx/media3/ui/SubtitlePainter.java
@@ -190,7 +190,7 @@ public void draw(
return;
}
- this.cueText = cue.text;
+ this.cueText = BidiUtils.containsRTL(cue.text) ? BidiUtils.wrapText(cue.text) : cue.text;
this.cueTextAlignment = cue.textAlignment;
this.cueBitmap = cue.bitmap;
this.cueLine = cue.line;
diff --git a/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java b/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java
new file mode 100644
index 00000000000..5ba2cee3b53
--- /dev/null
+++ b/libraries/ui/src/test/java/androidx/media3/ui/BidiUtilsTest.java
@@ -0,0 +1,109 @@
+/*
+ * Copyright (C) 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.media3.ui;
+
+import static org.junit.Assert.*;
+import org.junit.Test;
+import org.robolectric.RobolectricTestRunner;
+import org.junit.runner.RunWith;
+
+import android.text.SpannableStringBuilder;
+import android.text.Spanned;
+import android.text.style.StyleSpan;
+
+/** Tests for {@link BidiUtils}. */
+@RunWith(RobolectricTestRunner.class)
+public class BidiUtilsTest {
+
+ @Test
+ public void containsRTL_nullInput_returnsFalse() {
+ assertFalse(BidiUtils.containsRTL(null));
+ }
+
+ @Test
+ public void containsRTL_emptyString_returnsFalse() {
+ assertFalse(BidiUtils.containsRTL(""));
+ }
+
+ @Test
+ public void containsRTL_ltrOnly_returnsFalse() {
+ assertFalse(BidiUtils.containsRTL("Hello, world!"));
+ }
+
+ @Test
+ public void containsRTL_rtlOnly_returnsTrue() {
+ // Hebrew "שלום"
+ assertTrue(BidiUtils.containsRTL("שלום"));
+ }
+
+ @Test
+ public void containsRTL_mixedText_returnsTrue() {
+ // Mixed English and Arabic
+ assertTrue(BidiUtils.containsRTL("Hello مرحبا"));
+ }
+
+ @Test
+ public void wrapText_plainText_wrapsEachLineWithUnicodeWrap() {
+ String input = "להתראות.\nשלום\nשלום!";
+ CharSequence wrapped = BidiUtils.wrapText(input);
+
+ String[] lines = wrapped.toString().split("\n");
+
+ assertEquals(3, lines.length);
+ assertTrue(lines[0].contains("להתראות."));
+ assertTrue(lines[1].contains("שלום"));
+ assertTrue(lines[2].contains("שלום!"));
+
+ // Ensure wrapping occurred (Unicode control characters are added)
+ assertTrue(lines[0].length() > "להתראות.".length()
+ || lines[1].length() > "שלום".length()
+ || lines[2].length() > "שלום!".length());
+ }
+
+ @Test
+ public void wrapText_plainText_wrapsEachLineWithUnicodeWrap_CRLF() {
+ String input = "נסיון" + "\r\n" + "בחלונות";
+ CharSequence wrapped = BidiUtils.wrapText(input);
+
+ String[] lines = wrapped.toString().split("\n");
+
+ assertEquals(2, lines.length);
+ assertTrue(lines[0].contains("נסיון"));
+ assertTrue(lines[1].contains("בחלונות"));
+
+ // Ensure wrapping occurred (Unicode control characters are added)
+ assertTrue(lines[0].length() > "נסיון".length()
+ || lines[1].length() > "בחלונות".length());
+ }
+
+ @Test
+ public void wrapText_spansArePreserved() {
+ SpannableStringBuilder builder = new SpannableStringBuilder("שלום\nעולם");
+ StyleSpan boldSpan = new StyleSpan(android.graphics.Typeface.BOLD);
+ builder.setSpan(boldSpan, 0, 7, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
+
+ CharSequence wrapped = BidiUtils.wrapText(builder);
+
+ assertTrue(wrapped instanceof Spanned);
+ Spanned spanned = (Spanned) wrapped;
+
+ int start = spanned.getSpanStart(boldSpan);
+ int end = spanned.getSpanEnd(boldSpan);
+
+ assertTrue(start >= 0 && end > start);
+ }
+}
\ No newline at end of file