• Home
  • Line#
  • Scopes#
  • Navigate#
  • Raw
  • Download
1 /*
2  * Copyright (C) 2022 The Android Open Source Project
3  *
4  * Licensed under the Apache License, Version 2.0 (the "License");
5  * you may not use this file except in compliance with the License.
6  * You may obtain a copy of the License at
7  *
8  *      http://www.apache.org/licenses/LICENSE-2.0
9  *
10  * Unless required by applicable law or agreed to in writing, software
11  * distributed under the License is distributed on an "AS IS" BASIS,
12  * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13  * See the License for the specific language governing permissions and
14  * limitations under the License.
15  */
16 
17 package android.text;
18 
19 import static android.text.Layout.Alignment.ALIGN_NORMAL;
20 
21 import static com.google.common.truth.Truth.assertThat;
22 
23 import android.platform.test.annotations.Presubmit;
24 import android.platform.test.annotations.RequiresFlagsEnabled;
25 import android.platform.test.flag.junit.CheckFlagsRule;
26 import android.platform.test.flag.junit.DeviceFlagsValueProvider;
27 import android.text.method.OffsetMapping;
28 import android.text.style.UpdateLayout;
29 
30 import androidx.test.ext.junit.runners.AndroidJUnit4;
31 import androidx.test.filters.SmallTest;
32 
33 import com.android.text.flags.Flags;
34 
35 import org.junit.Rule;
36 import org.junit.Test;
37 import org.junit.runner.RunWith;
38 
39 @Presubmit
40 @SmallTest
41 @RunWith(AndroidJUnit4.class)
42 public class DynamicLayoutOffsetMappingTest {
43     private static final int WIDTH = 10000;
44     private static final TextPaint sTextPaint = new TextPaint();
45 
46     @Rule
47     public CheckFlagsRule mCheckFlagsRule = DeviceFlagsValueProvider.createCheckFlagsRule();
48 
49     @Test
textWithOffsetMapping()50     public void textWithOffsetMapping() {
51         final String text = "abcde";
52         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
53         final CharSequence transformedText = new TestOffsetMapping(spannable, 2, "\n");
54 
55         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
56                 .setAlignment(ALIGN_NORMAL)
57                 .setIncludePad(false)
58                 .setDisplayText(transformedText)
59                 .build();
60 
61         assertThat(transformedText.toString()).isEqualTo("ab\ncde");
62         assertLineRange(layout, /* lineBreaks */ 0, 3, 6);
63     }
64 
65     @Test
textWithOffsetMapping_deletion()66     public void textWithOffsetMapping_deletion() {
67         final String text = "abcdef";
68         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
69         final CharSequence transformedText =
70                 new TestOffsetMapping(spannable, 3, "\n\n");
71 
72         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
73                 .setAlignment(ALIGN_NORMAL)
74                 .setIncludePad(false)
75                 .setDisplayText(transformedText)
76                 .build();
77 
78         // delete character 'c', original text becomes "abdef"
79         spannable.delete(2, 3);
80         assertThat(transformedText.toString()).isEqualTo("ab\n\ndef");
81         assertLineRange(layout, /* lineBreaks */ 0, 3, 4, 7);
82 
83         // delete character 'd', original text becomes "abef"
84         spannable.delete(2, 3);
85         assertThat(transformedText.toString()).isEqualTo("ab\n\nef");
86         assertLineRange(layout, /* lineBreaks */ 0, 3, 4, 6);
87 
88         // delete "be", original text becomes "af"
89         spannable.delete(1, 3);
90         assertThat(transformedText.toString()).isEqualTo("a\n\nf");
91         assertLineRange(layout, /* lineBreaks */ 0, 2, 3, 4);
92     }
93 
94     @Test
textWithOffsetMapping_insertion()95     public void textWithOffsetMapping_insertion() {
96         final String text = "abcdef";
97         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
98         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
99 
100         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
101                 .setAlignment(ALIGN_NORMAL)
102                 .setIncludePad(false)
103                 .setDisplayText(transformedText)
104                 .build();
105 
106         spannable.insert(3, "x");
107         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndef");
108         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 9);
109 
110         spannable.insert(5, "x");
111         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndxef");
112         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 10);
113     }
114 
115     @Test
textWithOffsetMapping_replace()116     public void textWithOffsetMapping_replace() {
117         final String text = "abcdef";
118         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
119         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
120 
121         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
122                 .setAlignment(ALIGN_NORMAL)
123                 .setIncludePad(false)
124                 .setDisplayText(transformedText)
125                 .build();
126 
127         spannable.replace(2, 4, "xx");
128         assertThat(transformedText.toString()).isEqualTo("abxx\n\nef");
129         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 8);
130     }
131 
132     @Test
133     @RequiresFlagsEnabled(Flags.FLAG_INSERT_MODE_CRASH_UPDATE_LAYOUT_SPAN)
textWithOffsetMapping_deletion_withUpdateLayoutSpan()134     public void textWithOffsetMapping_deletion_withUpdateLayoutSpan() {
135         final String text = "abcdef";
136         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
137         // UpdateLayout span covers the letter 'd'.
138         spannable.setSpan(new UpdateLayout() {}, 3, 4, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
139 
140         final CharSequence transformedText =
141                 new TestOffsetMapping(spannable, 3, "\n\n");
142 
143         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
144                 .setAlignment(ALIGN_NORMAL)
145                 .setIncludePad(false)
146                 .setDisplayText(transformedText)
147                 .build();
148 
149         // delete character 'c', original text becomes "abdef"
150         spannable.delete(2, 3);
151         assertThat(transformedText.toString()).isEqualTo("ab\n\ndef");
152         assertLineRange(layout, /* lineBreaks */ 0, 3, 4, 7);
153 
154         // delete character 'd', original text becomes "abef"
155         spannable.delete(2, 3);
156         assertThat(transformedText.toString()).isEqualTo("ab\n\nef");
157         assertLineRange(layout, /* lineBreaks */ 0, 3, 4, 6);
158 
159         // delete "be", original text becomes "af"
160         spannable.delete(1, 3);
161         assertThat(transformedText.toString()).isEqualTo("a\n\nf");
162         assertLineRange(layout, /* lineBreaks */ 0, 2, 3, 4);
163     }
164 
165     @Test
166     @RequiresFlagsEnabled(Flags.FLAG_INSERT_MODE_CRASH_UPDATE_LAYOUT_SPAN)
textWithOffsetMapping_insert_withUpdateLayoutSpan()167     public void textWithOffsetMapping_insert_withUpdateLayoutSpan() {
168         final String text = "abcdef";
169         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
170         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
171 
172         // UpdateLayout span covers the letter 'de'.
173         spannable.setSpan(new UpdateLayout() {}, 3, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
174 
175         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
176                 .setAlignment(ALIGN_NORMAL)
177                 .setIncludePad(false)
178                 .setDisplayText(transformedText)
179                 .build();
180 
181         spannable.insert(3, "x");
182         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndef");
183         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 9);
184 
185         spannable.insert(5, "x");
186         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndxef");
187         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 10);
188     }
189 
190     @Test
191     @RequiresFlagsEnabled(Flags.FLAG_INSERT_MODE_CRASH_UPDATE_LAYOUT_SPAN)
textWithOffsetMapping_replace_withUpdateLayoutSpan()192     public void textWithOffsetMapping_replace_withUpdateLayoutSpan() {
193         final String text = "abcdef";
194         final SpannableStringBuilder spannable = new SpannableStringBuilder(text);
195         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
196         // UpdateLayout span covers the letter 'de'.
197         spannable.setSpan(new UpdateLayout() {}, 3, 5, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
198 
199         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
200                 .setAlignment(ALIGN_NORMAL)
201                 .setIncludePad(false)
202                 .setDisplayText(transformedText)
203                 .build();
204 
205         spannable.replace(2, 4, "xx");
206         assertThat(transformedText.toString()).isEqualTo("abxx\n\nef");
207         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 8);
208     }
209 
210     @Test
textWithOffsetMapping_blockBeforeTextChanged_deletion()211     public void textWithOffsetMapping_blockBeforeTextChanged_deletion() {
212         final String text = "abcdef";
213         final SpannableStringBuilder spannable = new TestNoBeforeTextChangeSpannableString(text);
214         final CharSequence transformedText =
215                 new TestOffsetMapping(spannable, 5, "\n\n");
216 
217         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
218                 .setAlignment(ALIGN_NORMAL)
219                 .setIncludePad(false)
220                 .setDisplayText(transformedText)
221                 .build();
222 
223         // delete "cd", original text becomes "abef"
224         spannable.delete(2, 4);
225         assertThat(transformedText.toString()).isEqualTo("abe\n\nf");
226         assertLineRange(layout, /* lineBreaks */ 0, 4, 5, 6);
227 
228         // delete "abe", original text becomes "f"
229         spannable.delete(0, 3);
230         assertThat(transformedText.toString()).isEqualTo("\n\nf");
231         assertLineRange(layout, /* lineBreaks */ 0, 1, 2, 3);
232     }
233 
234     @Test
textWithOffsetMapping_blockBeforeTextChanged_insertion()235     public void textWithOffsetMapping_blockBeforeTextChanged_insertion() {
236         final String text = "abcdef";
237         final SpannableStringBuilder spannable = new TestNoBeforeTextChangeSpannableString(text);
238         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
239 
240         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
241                 .setAlignment(ALIGN_NORMAL)
242                 .setIncludePad(false)
243                 .setDisplayText(transformedText)
244                 .build();
245 
246         spannable.insert(3, "x");
247         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndef");
248         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 9);
249 
250         spannable.insert(5, "x");
251         assertThat(transformedText.toString()).isEqualTo("abcx\n\ndxef");
252         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 10);
253     }
254 
255     @Test
textWithOffsetMapping_blockBeforeTextChanged_replace()256     public void textWithOffsetMapping_blockBeforeTextChanged_replace() {
257         final String text = "abcdef";
258         final SpannableStringBuilder spannable = new TestNoBeforeTextChangeSpannableString(text);
259         final CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
260 
261         final DynamicLayout layout = DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
262                 .setAlignment(ALIGN_NORMAL)
263                 .setIncludePad(false)
264                 .setDisplayText(transformedText)
265                 .build();
266 
267         spannable.replace(2, 4, "xx");
268         assertThat(transformedText.toString()).isEqualTo("abxx\n\nef");
269         assertLineRange(layout, /* lineBreaks */ 0, 5, 6, 8);
270     }
271 
272     @Test
textWithOffsetMapping_onlyCallOnTextChanged_notCrash()273     public void textWithOffsetMapping_onlyCallOnTextChanged_notCrash() {
274         String text = "abcdef";
275         SpannableStringBuilder spannable = new SpannableStringBuilder(text);
276         CharSequence transformedText = new TestOffsetMapping(spannable, 3, "\n\n");
277 
278         DynamicLayout.Builder.obtain(spannable, sTextPaint, WIDTH)
279                 .setAlignment(ALIGN_NORMAL)
280                 .setIncludePad(false)
281                 .setDisplayText(transformedText)
282                 .build();
283 
284         TextWatcher[] textWatcher = spannable.getSpans(0, spannable.length(), TextWatcher.class);
285         assertThat(textWatcher.length).isEqualTo(1);
286 
287         textWatcher[0].onTextChanged(spannable, 0, 2, 2);
288     }
289 
assertLineRange(Layout layout, int... lineBreaks)290     private void assertLineRange(Layout layout, int... lineBreaks) {
291         final int lineCount = lineBreaks.length - 1;
292         assertThat(layout.getLineCount()).isEqualTo(lineCount);
293         for (int line = 0; line < lineCount; ++line) {
294             assertThat(layout.getLineStart(line)).isEqualTo(lineBreaks[line]);
295         }
296         assertThat(layout.getLineEnd(lineCount - 1)).isEqualTo(lineBreaks[lineCount]);
297     }
298 
299     /**
300      * A test SpannableStringBuilder that doesn't call beforeTextChanged. It's used to test
301      * DynamicLayout against some special cases where beforeTextChanged callback is not properly
302      * called.
303      */
304     private static class TestNoBeforeTextChangeSpannableString extends SpannableStringBuilder {
305 
TestNoBeforeTextChangeSpannableString(CharSequence text)306         TestNoBeforeTextChangeSpannableString(CharSequence text) {
307             super(text);
308         }
309 
310         @Override
setSpan(Object what, int start, int end, int flags)311         public void setSpan(Object what, int start, int end, int flags) {
312             if (what instanceof TextWatcher) {
313                 super.setSpan(new TestNoBeforeTextChangeWatcherWrapper((TextWatcher) what), start,
314                         end, flags);
315             } else {
316                 super.setSpan(what, start, end, flags);
317             }
318         }
319     }
320 
321     /** A TextWatcherWrapper that blocks beforeTextChanged callback. */
322     private static class TestNoBeforeTextChangeWatcherWrapper implements TextWatcher {
323         private final TextWatcher mTextWatcher;
324 
TestNoBeforeTextChangeWatcherWrapper(TextWatcher textWatcher)325         TestNoBeforeTextChangeWatcherWrapper(TextWatcher textWatcher) {
326             mTextWatcher = textWatcher;
327         }
328 
329         @Override
beforeTextChanged(CharSequence s, int start, int count, int after)330         public void beforeTextChanged(CharSequence s, int start, int count, int after) { }
331 
332         @Override
onTextChanged(CharSequence s, int start, int before, int count)333         public void onTextChanged(CharSequence s, int start, int before, int count) {
334             mTextWatcher.onTextChanged(s, start, before, count);
335         }
336 
337         @Override
afterTextChanged(Editable s)338         public void afterTextChanged(Editable s) {
339             mTextWatcher.afterTextChanged(s);
340         }
341     }
342 
343     /**
344      * A test TransformedText that inserts some text at the given offset.
345      */
346     private static class TestOffsetMapping implements OffsetMapping, CharSequence {
347         private final int mOriginalInsertOffset;
348         private final CharSequence mOriginal;
349         private final CharSequence mInsertText;
TestOffsetMapping(CharSequence original, int insertOffset, CharSequence insertText)350         TestOffsetMapping(CharSequence original, int insertOffset,
351                 CharSequence insertText) {
352             mOriginal = original;
353             if (mOriginal instanceof Spannable) {
354                 ((Spannable) mOriginal).setSpan(INSERT_POINT, insertOffset, insertOffset,
355                         Spanned.SPAN_POINT_POINT);
356             }
357             mOriginalInsertOffset = insertOffset;
358             mInsertText = insertText;
359         }
360 
getInsertOffset()361         private int getInsertOffset() {
362             if (mOriginal instanceof Spannable) {
363                 return ((Spannable) mOriginal).getSpanStart(INSERT_POINT);
364             }
365             return mOriginalInsertOffset;
366         }
367 
368         @Override
originalToTransformed(int offset, int strategy)369         public int originalToTransformed(int offset, int strategy) {
370             final int insertOffset = getInsertOffset();
371             if (strategy == OffsetMapping.MAP_STRATEGY_CURSOR && offset == insertOffset) {
372                 return offset;
373             }
374             if (offset < getInsertOffset()) {
375                 return offset;
376             }
377             return offset + mInsertText.length();
378         }
379 
380         @Override
transformedToOriginal(int offset, int strategy)381         public int transformedToOriginal(int offset, int strategy) {
382             final int insertOffset = getInsertOffset();
383             if (offset < insertOffset) {
384                 return offset;
385             }
386             if (offset < insertOffset + mInsertText.length()) {
387                 return insertOffset;
388             }
389             return offset - mInsertText.length();
390         }
391 
392         @Override
originalToTransformed(TextUpdate textUpdate)393         public void originalToTransformed(TextUpdate textUpdate) {
394             final int insertOffset = getInsertOffset();
395             if (textUpdate.where <= insertOffset) {
396                 if (textUpdate.where + textUpdate.before > insertOffset) {
397                     textUpdate.before += mInsertText.length();
398                     textUpdate.after += mInsertText.length();
399                 }
400             } else {
401                 textUpdate.where += mInsertText.length();
402             }
403         }
404 
405         @Override
length()406         public int length() {
407             return mOriginal.length() + mInsertText.length();
408         }
409 
410         @Override
charAt(int index)411         public char charAt(int index) {
412             final int insertOffset = getInsertOffset();
413             if (index < insertOffset) {
414                 return mOriginal.charAt(index);
415             }
416             if (index < insertOffset + mInsertText.length()) {
417                 return mInsertText.charAt(index - insertOffset);
418             }
419             return mOriginal.charAt(index - mInsertText.length());
420         }
421 
422         @Override
subSequence(int start, int end)423         public CharSequence subSequence(int start, int end) {
424             StringBuilder stringBuilder = new StringBuilder();
425             for (int index = start; index < end; ++index) {
426                 stringBuilder.append(charAt(index));
427             }
428             return stringBuilder.toString();
429         }
430 
431         @Override
toString()432         public String toString() {
433             StringBuilder stringBuilder = new StringBuilder();
434             for (int index = 0; index < length(); ++index) {
435                 stringBuilder.append(charAt(index));
436             }
437             return stringBuilder.toString();
438         }
439 
440         static final NoCopySpan INSERT_POINT = new NoCopySpan() { };
441     }
442 }
443