fix: swallow LegacyCursorAnchorInfo IllegalArgumentException

Compose Foundation's LegacyCursorAnchorInfoBuilder.addCharacterBounds
computes a TextRange(startOffset, endOffset) over the visible portion of
a TextField when the IME has requested CURSOR_UPDATE_MONITOR. During a
layout transition (sheet animation, keyboard insets settling, etc.) the
visible region can momentarily collapse so startOffset == endOffset, and
MultiParagraph.fillBoundingBoxes throws "endOffset must be greater than
startOffset". This is a framework bug we can't fix from app code, and
it crashes the whole process from an onGloballyPositioned callback
during dispatchDraw.

Install a main-thread crash guard at app startup that wraps Looper.loop()
and swallows only this very specific signature (IllegalArgumentException
with the exact message AND a stack frame in LegacyCursorAnchorInfo* /
MultiParagraph* / AndroidParagraph / TextLayout). Anything else
re-throws into the default uncaught handler so UnexpectedCrashSaver
still records real bugs. The IME just misses a cursor update on the
swallowed frame — no user-visible impact.
This commit is contained in:
Claude
2026-05-20 19:36:33 +00:00
parent 5962f7d659
commit b0b0a13650
2 changed files with 89 additions and 0 deletions
@@ -21,6 +21,7 @@
package com.vitorpamplona.amethyst
import android.app.Application
import com.vitorpamplona.amethyst.service.crashreports.MainThreadCrashGuard
import com.vitorpamplona.amethyst.service.logging.Logging
import com.vitorpamplona.amethyst.service.nests.AppForegroundRecycleHook
import com.vitorpamplona.quartz.utils.Log
@@ -40,6 +41,12 @@ class Amethyst : Application() {
override fun onCreate() {
super.onCreate()
Log.d("AmethystApp") { "onCreate $this" }
// Wrap the main thread's Looper.loop() so we can survive a narrow set
// of known Compose framework crashes (currently: the LegacyCursorAnchorInfo
// IllegalArgumentException). Must be installed before any UI is shown.
MainThreadCrashGuard.install()
instance = AppModules(this)
// After-background foreground recycle: when the app returns to
@@ -0,0 +1,82 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.amethyst.service.crashreports
import android.os.Handler
import android.os.Looper
import com.vitorpamplona.quartz.utils.Log
/**
* Wraps the main thread's `Looper.loop()` so a narrow set of known, benign
* framework crashes can be swallowed instead of taking the whole process down.
*
* Only one case is handled today: `IllegalArgumentException("endOffset must be
* greater than startOffset")` thrown from
* `androidx.compose.foundation.text.input.internal.LegacyCursorAnchorInfoBuilder`.
* It fires when the IME has requested `CURSOR_UPDATE_MONITOR` on a TextField
* whose visible bounds momentarily collapse to a zero-width region (during a
* layout transition or sheet animation): `addCharacterBounds` computes
* `startOffset == endOffset` and `MultiParagraph.fillBoundingBoxes` rejects
* the empty range. The IME just misses a cursor update — the app does not
* need to die.
*
* Any other Throwable propagates so the default uncaught-exception handler
* (and our crash report saver) still see it.
*/
object MainThreadCrashGuard {
fun install() {
Handler(Looper.getMainLooper()).post {
while (true) {
try {
Looper.loop()
// Looper.loop() returned cleanly — main thread is shutting down.
return@post
} catch (e: Throwable) {
if (!isSwallowable(e)) throw e
Log.w("MainThreadCrashGuard", "Swallowed known framework crash: ${e.javaClass.name}: ${e.message}", e)
}
}
}
}
private fun isSwallowable(e: Throwable): Boolean = isComposeLegacyCursorAnchorInfoCrash(e)
private fun isComposeLegacyCursorAnchorInfoCrash(e: Throwable): Boolean {
if (e !is IllegalArgumentException) return false
if (e.message != "endOffset must be greater than startOffset") return false
// Cap the scan: in production this stack is ~25 frames; 40 is generous and bounds the
// cost of every main-thread exception we don't swallow.
val frames = e.stackTrace
val limit = minOf(frames.size, 40)
for (i in 0 until limit) {
val frame = frames[i]
val cls = frame.className
if (cls.contains("LegacyCursorAnchorInfo") ||
cls.contains("MultiParagraph") ||
cls.contains("AndroidParagraph") ||
cls.endsWith("TextLayout")
) {
return true
}
}
return false
}
}