From 91121cbb9fc2596d6f3e6f23a0fa72fd7491e2f6 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 21 Jan 2026 11:14:47 -0500 Subject: [PATCH 1/4] fix(BuildOutputFragment): defer text append to prevent initialization crash Ensure the editor is attached and laid out before flushing pending output to avoid ArrayIndexOutOfBoundsException. --- .../fragments/output/BuildOutputFragment.kt | 22 ++++++++++++++----- 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 86f868df4c..0c46c7d3ae 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -28,6 +28,9 @@ class BuildOutputFragment : NonEditableEditorFragment() { private val unsavedLines: MutableList = ArrayList() + private val IDEEditor.isReadyToAppend: Boolean + get() = !isReleased && isAttachedToWindow && isLaidOut + override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -35,12 +38,7 @@ class BuildOutputFragment : NonEditableEditorFragment() { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) - if (unsavedLines.isNotEmpty()) { - for (line in unsavedLines) { - editor?.append("${line!!.trim()}\n") - } - unsavedLines.clear() - } + editor?.post { flushPendingOutputIfReady() } } override fun onDestroyView() { @@ -65,4 +63,16 @@ class BuildOutputFragment : NonEditableEditorFragment() { } } } + + private fun flushPendingOutputIfReady() { + editor?.run { + if (!isReadyToAppend || unsavedLines.isEmpty()) return + + for (line in unsavedLines) { + append(line) + } + unsavedLines.clear() + emptyStateViewModel.setEmpty(false) + } + } } From 686d057eb75abe9421c76673d8bcb5fadf502c8c Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 21 Jan 2026 14:56:31 -0500 Subject: [PATCH 2/4] fix: missing newlines in pending output and move isReadyToAppend to IDEEditor --- .../androidide/fragments/output/BuildOutputFragment.kt | 9 ++++----- .../java/com/itsaky/androidide/editor/ui/IDEEditor.kt | 3 +++ 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 0c46c7d3ae..16e4802b99 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -28,9 +28,6 @@ class BuildOutputFragment : NonEditableEditorFragment() { private val unsavedLines: MutableList = ArrayList() - private val IDEEditor.isReadyToAppend: Boolean - get() = !isReleased && isAttachedToWindow && isLaidOut - override fun onViewCreated( view: View, savedInstanceState: Bundle?, @@ -68,8 +65,10 @@ class BuildOutputFragment : NonEditableEditorFragment() { editor?.run { if (!isReadyToAppend || unsavedLines.isEmpty()) return - for (line in unsavedLines) { - append(line) + unsavedLines.forEach { line -> + line?.let { + append(if (it.endsWith("\n")) it else "$it\n") + } } unsavedLines.clear() emptyStateViewModel.setEmpty(false) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index 9c3e61c9d6..b2b710173a 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -206,6 +206,9 @@ constructor( return _diagnosticWindow ?: DiagnosticWindow(this).also { _diagnosticWindow = it } } + val isReadyToAppend: Boolean + get() = !isReleased && isAttachedToWindow && isLaidOut + companion object { private const val TAG = "TrackpadScrollDebug" private const val SELECTION_CHANGE_DELAY = 500L From 0ddd34333c855d03ee0d2618ec412bba15720669 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 27 Jan 2026 12:55:47 -0500 Subject: [PATCH 3/4] refactor: prevent ANRs and layout crashes via async log batching - Implement Coroutine Channel to process logs off the main thread. - Add batching logic to reduce UI layout passes and prevent ANRs. - Add `awaitLayout` to ensure editor readiness, fixing ArrayIndexOutOfBoundsException. --- .../fragments/output/BuildOutputFragment.kt | 95 +++++++++++++------ .../itsaky/androidide/editor/ui/IDEEditor.kt | 58 ++++++++++- 2 files changed, 125 insertions(+), 28 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 16e4802b99..70112cb2f1 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -18,24 +18,29 @@ package com.itsaky.androidide.fragments.output import android.os.Bundle import android.view.View -import com.blankj.utilcode.util.ThreadUtils +import androidx.lifecycle.lifecycleScope import com.itsaky.androidide.R import com.itsaky.androidide.editor.ui.IDEEditor import com.itsaky.androidide.idetooltips.TooltipTag +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.ReceiveChannel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class BuildOutputFragment : NonEditableEditorFragment() { override val currentEditor: IDEEditor? get() = editor - private val unsavedLines: MutableList = ArrayList() + private val logChannel = Channel(Channel.UNLIMITED) - override fun onViewCreated( - view: View, - savedInstanceState: Bundle?, - ) { + override fun onViewCreated(view: View, savedInstanceState: Bundle?) { super.onViewCreated(view, savedInstanceState) editor?.tag = TooltipTag.PROJECT_BUILD_OUTPUT emptyStateViewModel.setEmptyMessage(getString(R.string.msg_emptyview_buildoutput)) - editor?.post { flushPendingOutputIfReady() } + + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Default) { + processLogs() + } } override fun onDestroyView() { @@ -44,33 +49,69 @@ class BuildOutputFragment : NonEditableEditorFragment() { } fun appendOutput(output: String?) { - if (editor == null) { - unsavedLines.add(output) - return + if (!output.isNullOrEmpty()) { + logChannel.trySend(output) } - ThreadUtils.runOnUiThread { - val message = - if (output == null || output.endsWith("\n")) { - output - } else { - "${output}\n" - } - editor?.append(message).also { - emptyStateViewModel.setEmpty(false) + } + + /** + * Ensures the string ends with a newline character (`\n`). + * Useful for maintaining correct formatting when concatenating log lines. + */ + private fun String.ensureNewline(): String = + if (endsWith('\n')) this else "$this\n" + + /** + * Immediately drains (consumes) all available messages from the channel into the [buffer]. + * + * This is a **non-blocking** operation that enables batching, grouping hundreds of pending lines + * into a single memory operation to avoid saturating the UI queue. + */ + private fun ReceiveChannel.drainTo(buffer: StringBuilder) { + var result = tryReceive() + while (result.isSuccess) { + val line = result.getOrNull() + if (!line.isNullOrEmpty()) { + buffer.append(line.ensureNewline()) } + result = tryReceive() } } - private fun flushPendingOutputIfReady() { - editor?.run { - if (!isReadyToAppend || unsavedLines.isEmpty()) return + /** + * Main log orchestrator: Consumes, Batches, and Dispatches. + * + * 1. Suspends (zero CPU usage) until the first log arrives. + * 2. Wakes up and drains the entire queue (Batching). + * 3. Sends the complete block to the UI in a single pass. + */ + private suspend fun processLogs() = with(StringBuilder()) { + for (firstLine in logChannel) { + append(firstLine.ensureNewline()) + logChannel.drainTo(this) - unsavedLines.forEach { line -> - line?.let { - append(if (it.endsWith("\n")) it else "$it\n") - } + if (isNotEmpty()) { + val batchText = toString() + clear() + flushToEditor(batchText) } - unsavedLines.clear() + } + } + + /** + * Performs the safe UI update on the Main Thread. + * + * Uses [IDEEditor.awaitLayout] to guarantee the editor has physical dimensions (width > 0) + * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. + */ + private suspend fun flushToEditor(text: String) = withContext(Dispatchers.Main) { + editor?.run { + awaitLayout(onForceVisible = { + emptyStateViewModel.setEmpty(false) + }) + + appendBatch(text) + emptyStateViewModel.setEmpty(false) } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt index b2b710173a..d1c461dd86 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/IDEEditor.kt @@ -24,6 +24,7 @@ import android.os.Handler import android.os.Looper import android.util.AttributeSet import android.view.MotionEvent +import android.view.View import android.view.inputmethod.EditorInfo import androidx.annotation.StringRes import androidx.annotation.VisibleForTesting @@ -97,12 +98,14 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.io.File +import kotlin.coroutines.resume fun interface OnEditorLongPressListener { fun onLongPress(event: MotionEvent) @@ -207,7 +210,7 @@ constructor( } val isReadyToAppend: Boolean - get() = !isReleased && isAttachedToWindow && isLaidOut + get() = !isReleased && isAttachedToWindow && isLaidOut && width > 0 companion object { private const val TAG = "TrackpadScrollDebug" @@ -269,6 +272,59 @@ constructor( } } + /** + * Suspends the current coroutine until the editor has valid dimensions (`width > 0`). + * + * This is a **reactive** alternative to busy-waiting or `postDelayed`. It ensures that + * no text insertion is attempted before the editor's internal layout engine is ready, + * preventing the `ArrayIndexOutOfBoundsException`. + * + * @param onForceVisible A callback invoked immediately if the view is not ready. + * Used to set the view to `VISIBLE` and trigger the layout pass. + */ + suspend fun awaitLayout(onForceVisible: () -> Unit) { + if (isReadyToAppend) return + + withContext(Dispatchers.Main) { + onForceVisible() + } + + return suspendCancellableCoroutine { continuation -> + val listener = object : OnLayoutChangeListener { + override fun onLayoutChange( + v: View?, left: Int, top: Int, right: Int, bottom: Int, + oldLeft: Int, oldTop: Int, oldRight: Int, oldBottom: Int + ) { + if ((v?.width ?: 0) > 0) { + v?.removeOnLayoutChangeListener(this) + if (continuation.isActive) { + continuation.resume(Unit) + } + } + } + } + + addOnLayoutChangeListener(listener) + + continuation.invokeOnCancellation { + removeOnLayoutChangeListener(listener) + } + } + } + + /** + * Appends a block of text to the editor safely. + * + * It performs a final check on [isReadyToAppend] and wraps the underlying append operation + * in [runCatching]. This prevents the app from crashing if the editor's internal layout + * calculation fails during the insertion. + */ + fun appendBatch(text: String) { + if (isReadyToAppend) { + runCatching { append(text) } + } + } + override fun setLanguageServer(server: ILanguageServer?) { if (isReleased) { return From c371b55dd6214d9b73d2c7844f92c1601088d17d Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Tue, 27 Jan 2026 13:37:25 -0500 Subject: [PATCH 4/4] fix: add timeout to awaitLayout to prevent infinite suspension --- .../fragments/output/BuildOutputFragment.kt | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt index 70112cb2f1..be2fd02ff8 100644 --- a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt +++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputFragment.kt @@ -27,8 +27,13 @@ import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.channels.ReceiveChannel import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull class BuildOutputFragment : NonEditableEditorFragment() { + companion object { + private const val LAYOUT_TIMEOUT_MS = 2000L + } + override val currentEditor: IDEEditor? get() = editor private val logChannel = Channel(Channel.UNLIMITED) @@ -106,9 +111,11 @@ class BuildOutputFragment : NonEditableEditorFragment() { */ private suspend fun flushToEditor(text: String) = withContext(Dispatchers.Main) { editor?.run { - awaitLayout(onForceVisible = { - emptyStateViewModel.setEmpty(false) - }) + withTimeoutOrNull(LAYOUT_TIMEOUT_MS) { + awaitLayout(onForceVisible = { + emptyStateViewModel.setEmpty(false) + }) + } appendBatch(text)