diff --git a/aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt b/aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt index 7fc395665..b33cc9b3e 100644 --- a/aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt +++ b/aztec/src/main/kotlin/org/wordpress/aztec/AztecText.kt @@ -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) diff --git a/aztec/src/main/kotlin/org/wordpress/aztec/formatting/LineBlockFormatter.kt b/aztec/src/main/kotlin/org/wordpress/aztec/formatting/LineBlockFormatter.kt index 4c7c35cd4..743bb8e86 100644 --- a/aztec/src/main/kotlin/org/wordpress/aztec/formatting/LineBlockFormatter.kt +++ b/aztec/src/main/kotlin/org/wordpress/aztec/formatting/LineBlockFormatter.kt @@ -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. + } } } diff --git a/aztec/src/main/kotlin/org/wordpress/aztec/watchers/SuggestionWatcher.kt b/aztec/src/main/kotlin/org/wordpress/aztec/watchers/SuggestionWatcher.kt index cc7a70345..13408fe7c 100644 --- a/aztec/src/main/kotlin/org/wordpress/aztec/watchers/SuggestionWatcher.kt +++ b/aztec/src/main/kotlin/org/wordpress/aztec/watchers/SuggestionWatcher.kt @@ -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. } } } diff --git a/aztec/src/test/kotlin/org/wordpress/aztec/IndentTest.kt b/aztec/src/test/kotlin/org/wordpress/aztec/IndentTest.kt index 732c7ba11..aad09f3af 100644 --- a/aztec/src/test/kotlin/org/wordpress/aztec/IndentTest.kt +++ b/aztec/src/test/kotlin/org/wordpress/aztec/IndentTest.kt @@ -248,6 +248,27 @@ class IndentTest { Assert.assertEquals("123
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() {