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