Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -1588,6 +1588,31 @@ open class AztecText : AppCompatEditText, TextWatcher, UnknownHtmlSpan.OnUnknown

// Helper ======================================================================================

/**
* Some Android versions/framework builds are very strict about selection offsets and will crash
* when selection handles try to position at an invalid cursor offset (e.g. -1 or > text length).
*
* Aztec can temporarily compute such offsets while mutating text (indent/outdent, span re-application, etc),
* so we clamp selection to a valid range here to avoid framework crashes.
*
* NOTE: We clamp to [0, safeLength] to avoid placing selection on the end-of-buffer marker.
*/
override fun setSelection(index: Int) {
val max = EndOfBufferMarkerAdder.safeLength(this)
super.setSelection(index.coerceIn(0, max))
}

override fun setSelection(start: Int, stop: Int) {
val max = EndOfBufferMarkerAdder.safeLength(this)
val s = start.coerceIn(0, max)
val e = stop.coerceIn(0, max)
if (s <= e) {
super.setSelection(s, e)
} else {
super.setSelection(e, s)
}
}

fun consumeCursorPosition(text: SpannableStringBuilder): Int {
var cursorPosition = Math.min(selectionStart, text.length)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,22 @@ class LineBlockFormatter(editor: AztecText) : AztecFormatter(editor) {
this.onEach { editableText.removeSpan(it.span) }
action()
this.onEach {
editableText.setSpan(it.span, it.spanStart, it.spanEnd, it.spanFlags)
val len = editableText.length
if (len <= 0) return@onEach

val start = it.spanStart.coerceIn(0, len)
val end = it.spanEnd.coerceIn(0, len)
if (start >= end) return@onEach

try {
editableText.setSpan(it.span, start, end, it.spanFlags)
} catch (_: IndexOutOfBoundsException) {
// Defensive: text may have been modified by other watchers by the time we restore spans.
} catch (_: IllegalArgumentException) {
// Defensive: some framework builds throw IllegalArgumentException on invalid ranges.
} catch (_: RuntimeException) {
// Defensive: SpannableStringBuilder.setSpan can throw RuntimeException for invalid ranges.
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -142,19 +142,26 @@ class SuggestionWatcher(aztecText: AztecText) : TextWatcher {

private fun reapplyCarriedOverInlineSpans(editableText: Spannable) {
carryOverSpans.forEach {
if (it.start >= 0 && it.end <= editableText.length && it.start < it.end) {
try {
editableText.setSpan(
it.span,
it.start,
it.end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
} catch (e: IndexOutOfBoundsException) {
// This is a workaround for a possible bug in the Android framework
// https://github.com/wordpress-mobile/WordPress-Android/issues/20481
e.printStackTrace()
}
val len = editableText.length
if (len <= 0) return@forEach

val start = it.start.coerceIn(0, len)
val end = it.end.coerceIn(0, len)
if (start >= end) return@forEach

try {
editableText.setSpan(
it.span,
start,
end,
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
} catch (_: IndexOutOfBoundsException) {
// Defensive: spans can become invalid if the framework modifies text concurrently.
} catch (_: IllegalArgumentException) {
// Defensive: newer framework builds can throw on edge-case span/selection interactions.
} catch (_: RuntimeException) {
// Defensive: SpannableStringBuilder.setSpan can throw RuntimeException for invalid ranges.
}
}
}
Expand Down
21 changes: 21 additions & 0 deletions aztec/src/test/kotlin/org/wordpress/aztec/IndentTest.kt
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,27 @@ class IndentTest {
Assert.assertEquals("123<br>456", editText.toHtml())
}

@Test
@Throws(Exception::class)
fun testOutdentAtStartOfDocumentDoesNotCrashAndClampsSelection() {
// Regression test:
// Outdenting when the caret is at position 0 and the first character is a tab used to compute
// selection = -1 and crash inside EditText.setSelection (setSpan(-1...-1)).
editText.fromHtml("\t123")

// Place caret at the very beginning (this matches the crash repro from the app).
editText.setSelection(0)
Assert.assertTrue(editText.isOutdentAvailable())

// Should not throw.
editText.outdent()

// Verify text was outdented and selection remained valid.
Assert.assertEquals("123", editText.toHtml())
Assert.assertEquals(0, editText.selectionStart)
Assert.assertEquals(0, editText.selectionEnd)
}

@Test
@Throws(Exception::class)
fun doesNotIndentMedia() {
Expand Down