diff --git a/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt
new file mode 100644
index 0000000000..b4cadb2cfe
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt
@@ -0,0 +1,160 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.fragments.output
+
+import kotlinx.coroutines.channels.Channel
+
+/**
+ * Thread-safe pending build output with fixed memory and batch budgets.
+ *
+ * Inputs are indivisible unless one exceeds [maxPendingChars], in which case only its newest tail
+ * is retained. Older pending output is evicted first, while batches preserve the retained order.
+ */
+internal class BuildOutputBuffer(
+ private val maxPendingChars: Int = DEFAULT_MAX_PENDING_CHARS,
+ private val maxBatchChars: Int = DEFAULT_MAX_BATCH_CHARS,
+) {
+ data class Batch(
+ val text: String,
+ val sessionToken: Int,
+ val sourceChars: Int,
+ val omittedLines: Long,
+ )
+
+ private sealed interface Entry {
+ val sessionToken: Int
+
+ data class Text(
+ val value: String,
+ override val sessionToken: Int,
+ ) : Entry
+
+ data class Omission(
+ var lineCount: Long,
+ var sourceChars: Int,
+ override val sessionToken: Int,
+ ) : Entry
+ }
+
+ private val entries = ArrayDeque()
+ private val available = Channel(Channel.CONFLATED)
+ private val lock = Any()
+ private var retainedChars = 0
+
+ internal val pendingChars: Int
+ get() = synchronized(lock) { retainedChars }
+
+ init {
+ require(maxPendingChars > 0) { "maxPendingChars must be positive" }
+ require(maxBatchChars > 0) { "maxBatchChars must be positive" }
+ }
+
+ fun offer(
+ text: String,
+ sessionToken: Int,
+ ) {
+ if (text.isEmpty()) return
+ val normalized = if (text.endsWith('\n')) text else "$text\n"
+ synchronized(lock) {
+ val existingOmission = entries.firstOrNull() as? Entry.Omission
+ if (existingOmission != null) entries.removeFirst()
+
+ var retained = normalized
+ var omittedLines = existingOmission?.lineCount ?: 0
+ var omittedChars = existingOmission?.sourceChars ?: 0
+ if (retained.length > maxPendingChars) {
+ val droppedPrefix = retained.dropLast(maxPendingChars)
+ omittedLines += lineCount(droppedPrefix)
+ omittedChars = saturatedAdd(omittedChars, droppedPrefix.length)
+ retained = retained.takeLast(maxPendingChars)
+ }
+ while (retainedChars > maxPendingChars - retained.length) {
+ val evicted = entries.removeFirst() as Entry.Text
+ retainedChars -= evicted.value.length
+ omittedLines += lineCount(evicted.value)
+ omittedChars = saturatedAdd(omittedChars, evicted.value.length)
+ }
+
+ if (omittedLines > 0) {
+ entries.addFirst(Entry.Omission(omittedLines, omittedChars, sessionToken))
+ }
+ entries.addLast(Entry.Text(retained, sessionToken))
+ retainedChars += retained.length
+ available.trySend(Unit)
+ }
+ }
+
+ suspend fun takeBatch(): Batch {
+ while (true) {
+ available.receive()
+ val batch = synchronized(lock) { takeAvailableBatch() }
+ if (batch != null) return batch
+ }
+ }
+
+ fun clear() {
+ synchronized(lock) {
+ entries.clear()
+ retainedChars = 0
+ while (available.tryReceive().isSuccess) {
+ // Discard stale wakeups from the cleared build session.
+ }
+ }
+ }
+
+ private fun takeAvailableBatch(): Batch? {
+ if (entries.isEmpty()) return null
+ val sessionToken = entries.first().sessionToken
+ val batch = StringBuilder(minOf(retainedChars, maxBatchChars))
+ var sourceChars = 0
+ var omittedLines = 0L
+ while (entries.isNotEmpty()) {
+ val entry = entries.first()
+ if (entry.sessionToken != sessionToken) break
+ if (entry is Entry.Text && batch.isNotEmpty() && batch.length + entry.value.length > maxBatchChars) break
+
+ entries.removeFirst()
+ when (entry) {
+ is Entry.Text -> {
+ batch.append(entry.value)
+ retainedChars -= entry.value.length
+ sourceChars = saturatedAdd(sourceChars, entry.value.length)
+ }
+ is Entry.Omission -> {
+ omittedLines += entry.lineCount
+ sourceChars = saturatedAdd(sourceChars, entry.sourceChars)
+ }
+ }
+ }
+ if (entries.isNotEmpty()) available.trySend(Unit)
+ return Batch(batch.toString(), sessionToken, sourceChars, omittedLines)
+ }
+
+ private fun lineCount(text: String): Long =
+ text.count { it == '\n' }.toLong() + if (text.endsWith('\n')) 0 else 1
+
+ private fun saturatedAdd(
+ left: Int,
+ right: Int,
+ ): Int = (left.toLong() + right).coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
+
+ companion object {
+ private const val DEFAULT_MAX_PENDING_CHARS = 256 * 1024
+ private const val DEFAULT_MAX_BATCH_CHARS = 32 * 1024
+ }
+}
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 6c5e4c42dc..146e6f4d93 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
@@ -36,9 +36,8 @@ import com.itsaky.androidide.utils.BasicBuildInfo
import com.itsaky.androidide.utils.dpToPx
import com.itsaky.androidide.utils.flashInfo
import com.itsaky.androidide.viewmodel.BuildOutputViewModel
+import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.Dispatchers
-import kotlinx.coroutines.channels.Channel
-import kotlinx.coroutines.channels.ReceiveChannel
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.drop
@@ -54,13 +53,9 @@ class BuildOutputFragment :
ViewOptionsOutputFragment {
private val buildOutputViewModel: BuildOutputViewModel by activityViewModels()
- companion object {
- private const val LAYOUT_TIMEOUT_MS = 2000L
- }
-
override val currentEditor: IDEEditor? get() = editor
- private val logChannel = Channel(Channel.UNLIMITED)
+ private val outputBuffer = BuildOutputBuffer()
private var searchLayout: EditorSearchLayout? = null
private var filterBar: LogFilterBarController? = null
@@ -69,15 +64,17 @@ class BuildOutputFragment :
// so a re-render never misses or duplicates a concurrently flushed batch.
private val editorContentMutex = Mutex()
- // Bumped only when a build session is cleared (new build) so live streaming logs
- // are never dropped from the disk session file during filter re-renders.
- @Volatile
- private var sessionGeneration = 0
+ // Keeps producer-side disk appends ordered and provides an atomic restore snapshot boundary.
+ private val appendMutex = Mutex()
// Bumped on every wholesale content replacement (filtered re-render or clear) so an
// in-flight batch flush drained before the replacement can detect it and drop itself.
@Volatile
private var editorContentGeneration = 0
+
+ // Written on Main and read by the background batch processor.
+ @Volatile
+ private var editorSourceChars = 0
private val noMatchTracker = FilterNoMatchTracker()
// Reads view state (bar visibility), so evaluate it on the main thread.
@@ -95,11 +92,15 @@ class BuildOutputFragment :
setupSearchLayout()
viewLifecycleOwner.lifecycleScope.launch {
- launch { restoreWindowFromViewModel() }
- launch(Dispatchers.Default) { processLogs() }
launch {
- val content = buildOutputViewModel.getFullContent()
- buildOutputViewModel.setCachedSnapshot(content)
+ try {
+ restoreWindowFromViewModel()
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.error("Failed to restore build output to the editor", e)
+ }
+ withContext(Dispatchers.Default) { processLogs() }
}
launch {
combine(
@@ -121,15 +122,23 @@ class BuildOutputFragment :
showTimestamps: Boolean = buildOutputViewModel.showTimestamps.value,
showDeltas: Boolean = buildOutputViewModel.showDeltas.value,
) {
- editorContentMutex.withLock {
- editorContentGeneration++
- val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
- val filtered =
- withContext(Dispatchers.Default) {
- BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas)
- }
+ val renderGeneration =
withContext(Dispatchers.Main) {
- editor?.setText(filtered)
+ editorContentGeneration++
+ editorContentGeneration
+ }
+ val window =
+ snapshotEditorWindow()
+ val filtered =
+ withContext(Dispatchers.Default) {
+ BuildOutputViewModel.filterLines(window, query, showTimestamps, showDeltas)
+ }
+ withContext(Dispatchers.Main) {
+ editorContentMutex.withLock {
+ if (renderGeneration != editorContentGeneration) return@withLock
+ val editor = editor ?: return@withLock
+ editor.setText(filtered)
+ editorSourceChars = BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
val isSourceEmpty = window.isBlank()
updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
if (noMatchTracker.onRender(isSourceEmpty = isSourceEmpty, isFilteredEmpty = filtered.isBlank())) {
@@ -287,61 +296,81 @@ class BuildOutputFragment :
}.also { filterBar = it }
}
- private suspend fun restoreWindowFromViewModel() {
- val window = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
- val content =
- BuildOutputViewModel.filterLines(
- window,
- buildOutputViewModel.filterText.value,
- buildOutputViewModel.showTimestamps.value,
- buildOutputViewModel.showDeltas.value,
- )
- val query = buildOutputViewModel.filterText.value
- val isSourceEmpty = window.isBlank()
- val isFilteredEmpty = content.isBlank()
+ private suspend fun restoreWindowFromViewModel() =
+ withContext(Dispatchers.Default) {
+ val generationAtRestore = editorContentGeneration
+ val window = snapshotEditorWindow()
+ val content =
+ BuildOutputViewModel.filterLines(
+ window,
+ buildOutputViewModel.filterText.value,
+ buildOutputViewModel.showTimestamps.value,
+ buildOutputViewModel.showDeltas.value,
+ )
+ fun isRestoreCurrent() = editorContentGeneration == generationAtRestore
+ val isSourceEmpty = window.isBlank()
+ val isFilteredEmpty = content.isBlank()
- withContext(Dispatchers.Main) {
- updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
- noMatchTracker.prime(isFilteredEmpty)
- if (!isSourceEmpty && isFilteredEmpty) {
- editor?.setText("")
- onContentReplaced()
+ withContext(Dispatchers.Main) {
+ editorContentMutex.withLock {
+ if (!isRestoreCurrent()) return@withLock
+ updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
+ noMatchTracker.prime(isFilteredEmpty)
+ if (!isSourceEmpty && isFilteredEmpty) {
+ editor?.run {
+ setText("")
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ onContentReplaced()
+ }
+ }
+ }
}
- }
- if (content.isEmpty()) return
- withContext(Dispatchers.Main) {
- val editor = this@BuildOutputFragment.editor ?: return@withContext
- val layoutCompleted =
- withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
- editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
- }
- if (layoutCompleted != null) {
- editor.appendBatch(content)
- updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
- } else {
- // Timeout: defer append until layout is ready so content is not lost
- val generationAtRestore = editorContentGeneration
- val job =
- viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
- editor.run {
- awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
- editorContentMutex.withLock {
- if (editorContentGeneration == generationAtRestore) {
- appendBatch(content)
- updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
- }
+ if (content.isEmpty()) return@withContext
+ withContext(Dispatchers.Main) {
+ val editor = this@BuildOutputFragment.editor ?: return@withContext
+ val layoutCompleted =
+ withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
+ editor.awaitLayout {
+ if (isRestoreCurrent()) {
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ }
+ }
+ }
+ if (layoutCompleted != null) {
+ editorContentMutex.withLock {
+ if (isRestoreCurrent() && editor.appendBatchIfReady(content)) {
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ }
+ }
+ } else {
+ // Replace content without waiting indefinitely for an offscreen editor's layout.
+ editorContentMutex.withLock {
+ if (isRestoreCurrent()) {
+ val contentReplaced =
+ runCatching { editor.setText(content) }
+ .onFailure { log.error("Failed to restore build output before editor layout", it) }
+ .isSuccess
+ if (contentReplaced) {
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ onContentReplaced()
}
}
}
- job.join()
+ }
}
}
- }
override fun onDestroyView() {
searchLayout = null
filterBar = null
+ editorContentGeneration++
+ editorSourceChars = 0
editor?.release()
super.onDestroyView()
}
@@ -351,13 +380,9 @@ class BuildOutputFragment :
// Avoid forcing the activityViewModels lazy init (which calls requireActivity())
// when the fragment is detached, otherwise an IllegalStateException is thrown.
if (!isAdded || activity == null) return
- while (logChannel.tryReceive().isSuccess) {
- // Discard: these lines belong to the session being cleared.
- }
- // Invalidate in-flight flushes before deleting content, so a batch drained from the
- // channel earlier cannot re-seed the cleared session.
- sessionGeneration++
+ outputBuffer.clear()
editorContentGeneration++
+ editorSourceChars = 0
noMatchTracker.reset()
buildOutputViewModel.clear()
super.clearOutput()
@@ -376,119 +401,170 @@ class BuildOutputFragment :
}
fun appendOutput(output: String?) {
- if (!output.isNullOrEmpty()) {
- logChannel.trySend(output)
+ if (!isAdded || activity == null) return
+ val text = output ?: return
+ if (text.isEmpty()) return
+ val sessionToken = buildOutputViewModel.currentSessionToken
+ lifecycleScope.launch {
+ appendMutex.withLock {
+ val normalized =
+ withContext(Dispatchers.Default) {
+ if (text.endsWith('\n')) text else "$text\n"
+ }
+ if (
+ buildOutputViewModel.append(normalized, sessionToken) &&
+ buildOutputViewModel.isCurrentSession(sessionToken)
+ ) {
+ outputBuffer.offer(normalized, sessionToken)
+ }
+ }
}
}
- /**
- * 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 suspend fun snapshotEditorWindow(): String =
+ appendMutex.withLock {
+ val snapshot = withContext(Dispatchers.IO) { buildOutputViewModel.getWindowForEditor() }
+ // The snapshot already contains everything persisted before this boundary.
+ outputBuffer.clear()
+ buildOutputViewModel.setCachedSnapshot(snapshot)
+ snapshot
}
- }
/**
* 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.
+ * Suspends until bounded output is available, then sends one bounded batch to the UI.
*/
- private suspend fun processLogs() =
- with(StringBuilder()) {
- for (firstLine in logChannel) {
- val sessionGenAtDrain = sessionGeneration
- val editorGenAtDrain = editorContentGeneration
- append(firstLine.ensureNewline())
- logChannel.drainTo(this)
-
- if (isNotEmpty()) {
- val batchText = toString()
- clear()
- flushToEditor(batchText, sessionGenAtDrain, editorGenAtDrain)
- }
+ private suspend fun processLogs() {
+ while (true) {
+ val batch = outputBuffer.takeBatch()
+ val editorGenAtDrain = editorContentGeneration
+ try {
+ flushToEditor(
+ batch.text,
+ batch.sourceChars,
+ batch.omittedLines,
+ batch.sessionToken,
+ editorGenAtDrain,
+ )
+ } catch (e: CancellationException) {
+ throw e
+ } catch (e: Exception) {
+ log.error("Failed to flush a build output batch to the editor", e)
}
}
+ }
/**
* Performs the safe UI update on the Main Thread.
*
- * Appends to the session file on a background dispatcher before switching to Main.
+ * Applies output already persisted by the producer before switching to Main.
* 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(
+ internal suspend fun flushToEditor(
text: String,
- sessionGen: Int,
+ sourceChars: Int,
+ omittedLines: Long,
+ sessionToken: Int,
editorGen: Int,
) {
- editorContentMutex.withLock {
- // A clear (new build) after this batch was drained invalidates session append.
- if (sessionGen != sessionGeneration) return
-
- buildOutputViewModel.append(text)
-
- // The session file always gets the full text; the editor only shows matching lines
- val visibleText =
- BuildOutputViewModel.filterLines(
- text,
- buildOutputViewModel.filterText.value,
- buildOutputViewModel.showTimestamps.value,
- buildOutputViewModel.showDeltas.value,
- )
- if (visibleText.isEmpty()) {
- return
+ if (!buildOutputViewModel.isCurrentSession(sessionToken)) return
+ // A filter render or clear can make this snapshot stale; the generation check before applying
+ // the batch makes that harmless without adding a Main-thread dispatch to every batch.
+ val refreshEditorWindow =
+ BuildOutputViewModel.wouldExceedEditorWindow(editorSourceChars, sourceChars)
+ val visibleText =
+ BuildOutputViewModel.filterLines(
+ text,
+ buildOutputViewModel.filterText.value,
+ buildOutputViewModel.showTimestamps.value,
+ buildOutputViewModel.showDeltas.value,
+ )
+ val refreshedWindow =
+ if (refreshEditorWindow) {
+ val window =
+ appendMutex.withLock {
+ val snapshot = buildOutputViewModel.getCachedContentSnapshot()
+ outputBuffer.clear()
+ snapshot
+ }
+ withContext(Dispatchers.Default) {
+ Pair(
+ BuildOutputViewModel.filterLines(
+ window,
+ buildOutputViewModel.filterText.value,
+ buildOutputViewModel.showTimestamps.value,
+ buildOutputViewModel.showDeltas.value,
+ ),
+ window.length,
+ )
+ }
+ } else {
+ null
}
- withContext(Dispatchers.Main) {
- updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
- if (visibleText.isEmpty()) {
- return@withContext
- }
- editor?.run {
- val layoutCompleted =
- withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
- awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
- }
- if (layoutCompleted != null) {
- // clearOutput() or renderFiltered() may have run since the file append.
- if (editorGen == editorContentGeneration) {
- appendBatch(visibleText)
- updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
- }
- } else {
- // Timeout: defer append until layout is ready (same as restoreWindowFromViewModel)
- viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) {
- editor?.run {
- awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
- editorContentMutex.withLock {
- if (editorGen == editorContentGeneration) {
- appendBatch(visibleText)
- updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
- }
- }
+ withContext(Dispatchers.Main) {
+ val editor = editor ?: return@withContext
+ val needsAppend = refreshedWindow == null && (visibleText.isNotEmpty() || omittedLines > 0)
+ if (needsAppend) {
+ val layoutCompleted =
+ withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
+ editor.awaitLayout {
+ if (editorGen == editorContentGeneration) {
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
}
+ // The batch is already persisted and will be recovered by the next window snapshot.
+ if (layoutCompleted == null) return@withContext
+ }
+ editorContentMutex.withLock {
+ if (
+ editorGen != editorContentGeneration ||
+ !buildOutputViewModel.isCurrentSession(sessionToken)
+ ) {
+ return@withLock
+ }
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ if (refreshedWindow != null) {
+ editorContentGeneration++
+ editor.setText(refreshedWindow.first)
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(refreshedWindow.second)
+ onContentReplaced()
+ return@withLock
+ }
+ val omissionMarker =
+ if (omittedLines > 0) formatOmissionMarker(omittedLines) else ""
+ val textToAppend = omissionMarker + visibleText
+ if (textToAppend.isEmpty()) {
+ editorSourceChars += sourceChars
+ return@withLock
+ }
+ if (editor.appendBatchIfReady(textToAppend)) {
+ editorSourceChars += sourceChars
}
}
}
}
+
+ private fun formatOmissionMarker(lineCount: Long): String {
+ val quantity = lineCount.coerceAtMost(Int.MAX_VALUE.toLong()).toInt()
+ return resources
+ .getQuantityString(R.plurals.msg_build_output_lines_omitted, quantity, lineCount)
+ .plus('\n')
+ }
+
+ private fun IDEEditor.appendBatchIfReady(text: String): Boolean {
+ if (!isReadyToAppend) return false
+ val previousLength = this.text.length
+ appendBatch(text)
+ return this.text.length == previousLength + text.length
+ }
+
+ companion object {
+ private const val LAYOUT_TIMEOUT_MS = 2000L
+ private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputFragment::class.java)
+ }
}
diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt
index dc94377062..a3d7ce47c2 100644
--- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt
+++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt
@@ -46,6 +46,19 @@ class BuildOutputViewModel(
application: Application,
) : AndroidViewModel(application) {
private val lock = ReentrantLock()
+ private val cachedContentSnapshot = StringBuilder()
+ // Reused until clear/onCleared so noisy builds do not open and close the file per line.
+ private var sessionOutputStream: FileOutputStream? = null
+
+ @Volatile
+ private var sessionGeneration = 0
+
+ /** Token for output produced by the current build session. */
+ val currentSessionToken: Int
+ get() = sessionGeneration
+
+ /** Returns whether [token] still belongs to the current build session. */
+ fun isCurrentSession(token: Int): Boolean = token == sessionGeneration
/**
* Case-insensitive line filter applied to the *editor view* of the build output.
@@ -63,24 +76,15 @@ class BuildOutputViewModel(
val showLineNumbers = MutableStateFlow(EditorPreferences.outputLineNumbers)
/**
- * Thread-safe snapshot of content for synchronous [getShareableContent] without blocking.
- * Updated on [append] and [clear]; primed on restore via [setCachedSnapshot].
- * Capped at [CACHE_SNAPSHOT_MAX_CHARS] to bound memory.
+ * Returns the thread-safe cached snapshot for synchronous share/copy. Updated on [append] and
+ * [clear], primed on restore via [setCachedSnapshot], and capped at
+ * [CACHE_SNAPSHOT_MAX_CHARS].
*/
- @Volatile
- private var cachedContentSnapshot: String = ""
-
- /** Returns the current cached snapshot for share/copy (non-blocking). */
- fun getCachedContentSnapshot(): String = cachedContentSnapshot
+ fun getCachedContentSnapshot(): String = lock.withLock { cachedContentSnapshot.toString() }
/** Updates the cached snapshot (e.g. after loading full content on restore). Capped to [CACHE_SNAPSHOT_MAX_CHARS]. */
fun setCachedSnapshot(content: String) {
- cachedContentSnapshot =
- if (content.length <= CACHE_SNAPSHOT_MAX_CHARS) {
- content
- } else {
- content.takeLast(CACHE_SNAPSHOT_MAX_CHARS)
- }
+ lock.withLock { replaceCachedSnapshot(content) }
}
private val sessionFile: File
@@ -88,32 +92,41 @@ class BuildOutputViewModel(
/**
* Appends text to the session file. File I/O is performed on a background dispatcher; call from
- * any thread. Prefer calling before switching to Main so disk write does not block the UI.
+ * any thread. [sessionToken] values invalidated by [clear] are rejected inside the file lock.
*/
- suspend fun append(text: String) {
- if (text.isEmpty()) return
- withContext(Dispatchers.IO) {
+ suspend fun append(
+ text: String,
+ sessionToken: Int,
+ ): Boolean {
+ if (text.isEmpty()) return false
+ return withContext(Dispatchers.IO) {
lock.withLock {
+ if (!isCurrentSession(sessionToken)) return@withLock false
try {
- FileOutputStream(sessionFile, true).use {
- it.write(text.toByteArray(StandardCharsets.UTF_8))
- }
- cachedContentSnapshot =
- (cachedContentSnapshot + text).takeLast(CACHE_SNAPSHOT_MAX_CHARS)
+ val output =
+ sessionOutputStream
+ ?: FileOutputStream(sessionFile, true).also {
+ sessionOutputStream = it
+ }
+ output.write(text.toByteArray(StandardCharsets.UTF_8))
+ appendCachedSnapshot(text)
+ true
} catch (e: Exception) {
+ closeSessionOutputStream()
log.error("Failed to append build output to session file", e)
+ false
}
}
}
}
/**
- * Returns the last [WINDOW_SIZE_CHARS] characters from the session file for the editor to
+ * Returns the last [EDITOR_WINDOW_MAX_CHARS] characters from the session file for the editor to
* display (e.g. initial view or after rotation). Returns empty string if no content.
*/
fun getWindowForEditor(): String =
lock.withLock {
- readTailFromFile(sessionFile, WINDOW_SIZE_CHARS)
+ readTailFromFile(sessionFile, EDITOR_WINDOW_MAX_CHARS)
}
/**
@@ -159,7 +172,9 @@ class BuildOutputViewModel(
*/
fun clear() {
lock.withLock {
- cachedContentSnapshot = ""
+ sessionGeneration++
+ closeSessionOutputStream()
+ cachedContentSnapshot.setLength(0)
try {
if (sessionFile.exists()) {
sessionFile.delete()
@@ -170,6 +185,37 @@ class BuildOutputViewModel(
}
}
+ override fun onCleared() {
+ lock.withLock { closeSessionOutputStream() }
+ super.onCleared()
+ }
+
+ private fun appendCachedSnapshot(text: String) {
+ if (text.length >= CACHE_SNAPSHOT_MAX_CHARS) {
+ replaceCachedSnapshot(text)
+ return
+ }
+ val overflow = cachedContentSnapshot.length + text.length - CACHE_SNAPSHOT_MAX_CHARS
+ if (overflow > 0) cachedContentSnapshot.delete(0, overflow)
+ cachedContentSnapshot.append(text)
+ }
+
+ private fun replaceCachedSnapshot(content: String) {
+ cachedContentSnapshot.setLength(0)
+ val start = (content.length - CACHE_SNAPSHOT_MAX_CHARS).coerceAtLeast(0)
+ cachedContentSnapshot.append(content, start, content.length)
+ }
+
+ private fun closeSessionOutputStream() {
+ try {
+ sessionOutputStream?.close()
+ } catch (e: Exception) {
+ log.error("Failed to close build output session file", e)
+ } finally {
+ sessionOutputStream = null
+ }
+ }
+
private fun readTailFromFile(
file: File,
maxChars: Int,
@@ -194,6 +240,19 @@ class BuildOutputViewModel(
}
companion object {
+ internal const val EDITOR_WINDOW_MAX_CHARS = 512 * 1024
+ private const val EDITOR_WINDOW_REFRESH_CHARS = 128 * 1024
+ private const val EDITOR_WINDOW_REFRESH_BASE_CHARS =
+ EDITOR_WINDOW_MAX_CHARS - EDITOR_WINDOW_REFRESH_CHARS
+
+ internal fun wouldExceedEditorWindow(
+ currentChars: Int,
+ incomingChars: Int,
+ ): Boolean = currentChars > EDITOR_WINDOW_MAX_CHARS - incomingChars
+
+ internal fun editorSourceCharsAfterRefresh(windowChars: Int): Int =
+ windowChars.coerceAtMost(EDITOR_WINDOW_REFRESH_BASE_CHARS)
+
// Must mirror formatLinePrefix exactly; the round-trip is covered by BuildOutputFilterTest.
// Anchored to line start so timestamp-shaped text inside a message is never stripped.
private val PREFIX_REGEX =
@@ -261,10 +320,8 @@ class BuildOutputViewModel(
}
private const val SESSION_FILE_NAME = "build_output_session.txt"
- private const val WINDOW_SIZE_CHARS = 512 * 1024
-
/** Max length of [cachedContentSnapshot] to bound memory. */
- private const val CACHE_SNAPSHOT_MAX_CHARS = WINDOW_SIZE_CHARS
+ private const val CACHE_SNAPSHOT_MAX_CHARS = EDITOR_WINDOW_MAX_CHARS
private val log = org.slf4j.LoggerFactory.getLogger(BuildOutputViewModel::class.java)
}
}
diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt
new file mode 100644
index 0000000000..ca90f1070e
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt
@@ -0,0 +1,225 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.fragments.output
+
+import android.app.Application
+import androidx.test.core.app.ApplicationProvider
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.viewmodel.BuildOutputViewModel
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.RobolectricTestRunner
+
+@RunWith(RobolectricTestRunner::class)
+class BuildOutputBufferTest {
+ private fun BuildOutputBuffer.offer(text: String) {
+ offer(text, sessionToken = 0)
+ }
+
+ @Test
+ fun `output below limits is emitted in order with one trailing newline`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64)
+
+ buffer.offer("first")
+ buffer.offer("second\n")
+
+ assertThat(buffer.takeBatch().text).isEqualTo("first\nsecond\n")
+ }
+
+ @Test
+ fun `output is split into bounded batches without reordering`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 6)
+
+ buffer.offer("aa")
+ buffer.offer("bb")
+ buffer.offer("cc")
+
+ val first = buffer.takeBatch().text
+ val second = buffer.takeBatch().text
+ assertThat(first).isEqualTo("aa\nbb\n")
+ assertThat(second).isEqualTo("cc\n")
+ assertThat(first.length).isAtMost(6)
+ assertThat(second.length).isAtMost(6)
+ }
+
+ @Test
+ fun `one indivisible input may exceed the batch limit`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 4)
+
+ buffer.offer("oversized")
+
+ assertThat(buffer.takeBatch().text).isEqualTo("oversized\n")
+ }
+
+ @Test
+ fun `overflow evicts oldest output and keeps newest output`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 11, maxBatchChars = 128)
+
+ buffer.offer("one")
+ buffer.offer("two")
+ buffer.offer("three")
+ buffer.offer("four")
+
+ val batch = buffer.takeBatch()
+ assertThat(batch.text).isEqualTo("three\nfour\n")
+ assertThat(batch.omittedLines).isEqualTo(2)
+ assertThat(batch.sourceChars).isEqualTo(19)
+ assertThat(buffer.pendingChars).isAtMost(11)
+ }
+
+ @Test
+ fun `oversized input retains its newest bounded tail`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 8, maxBatchChars = 128)
+
+ buffer.offer("0123456789")
+
+ val batch = buffer.takeBatch()
+ assertThat(batch.text).isEqualTo("3456789\n")
+ assertThat(batch.omittedLines).isEqualTo(1)
+ assertThat(batch.sourceChars).isEqualTo(11)
+ assertThat(buffer.pendingChars).isEqualTo(0)
+ }
+
+ @Test
+ fun `clear resets pending output and overflow accounting`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 4, maxBatchChars = 64)
+
+ buffer.offer("kept")
+ buffer.offer("dropped")
+ buffer.clear()
+ buffer.offer("new")
+
+ assertThat(buffer.takeBatch().text).isEqualTo("new\n")
+ }
+
+ @Test
+ fun `in-flight batch keeps the session token from its producer`() =
+ runTest {
+ val buffer = BuildOutputBuffer(maxPendingChars = 64, maxBatchChars = 64)
+
+ buffer.offer("old", sessionToken = 3)
+ val inFlight = buffer.takeBatch()
+ buffer.clear()
+ buffer.offer("new", sessionToken = 4)
+
+ assertThat(inFlight.sessionToken).isEqualTo(3)
+ assertThat(buffer.takeBatch().sessionToken).isEqualTo(4)
+ }
+
+ @Test
+ fun `clear invalidates stale view model session tokens`() =
+ runTest {
+ val viewModel =
+ BuildOutputViewModel(ApplicationProvider.getApplicationContext())
+ viewModel.clear()
+ val staleToken = viewModel.currentSessionToken
+
+ assertThat(viewModel.append("old\n", staleToken)).isTrue()
+ viewModel.clear()
+
+ assertThat(viewModel.append("stale\n", staleToken)).isFalse()
+ assertThat(viewModel.getFullContent()).isEmpty()
+ }
+
+ @Test
+ fun `repeated appends retain the complete editor window in the bounded cache`() =
+ runTest {
+ val viewModel = BuildOutputViewModel(ApplicationProvider.getApplicationContext())
+ viewModel.clear()
+ val first = "a".repeat(384 * 1024)
+ val second = "b".repeat(256 * 1024)
+ val fullOutput = first + second
+ assertThat(viewModel.append(first, viewModel.currentSessionToken)).isTrue()
+ assertThat(viewModel.append(second, viewModel.currentSessionToken)).isTrue()
+ assertThat(viewModel.getCachedContentSnapshot())
+ .isEqualTo(fullOutput.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS))
+ assertThat(viewModel.getWindowForEditor())
+ .isEqualTo(fullOutput.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS))
+ assertThat(viewModel.getFullContent()).isEqualTo(fullOutput)
+ viewModel.clear()
+ }
+
+ @Test
+ fun `window refresh uses hysteresis after reaching the editor limit`() {
+ val batchChars = 32 * 1024
+ var sourceChars = BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS
+ var refreshCount = 0
+
+ repeat(6) {
+ if (BuildOutputViewModel.wouldExceedEditorWindow(sourceChars, batchChars)) {
+ refreshCount++
+ sourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(
+ BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS,
+ )
+ } else {
+ sourceChars += batchChars
+ }
+ }
+
+ assertThat(refreshCount).isEqualTo(2)
+ }
+
+ @Test
+ fun `filtered editor refreshes when hidden source output advances the window`() {
+ val oldMatch = "old match\n"
+ val hidden = "x".repeat(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS)
+ var session = oldMatch
+ var sourceChars = session.length
+ var visible = oldMatch
+
+ session += hidden
+ if (BuildOutputViewModel.wouldExceedEditorWindow(sourceChars, hidden.length)) {
+ val window = session.takeLast(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS)
+ visible =
+ BuildOutputViewModel.filterLines(
+ window,
+ query = "match",
+ showTimestamps = true,
+ showDeltas = true,
+ )
+ sourceChars = BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ }
+
+ assertThat(visible).isEmpty()
+ assertThat(sourceChars).isLessThan(BuildOutputViewModel.EDITOR_WINDOW_MAX_CHARS)
+ }
+
+ @Test
+ fun `refreshed tail applies filtering and timing visibility`() {
+ val prefix = BuildOutputViewModel.formatLinePrefix(1_722_000_000_000L, 42L)
+ val tail = prefix + "ignored\n" + prefix + "newest output\n"
+
+ val visible =
+ BuildOutputViewModel.filterLines(
+ tail,
+ query = "newest",
+ showTimestamps = false,
+ showDeltas = false,
+ )
+
+ assertThat(visible).isEqualTo("newest output\n")
+ }
+}
diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt
index 9f0d3e0a70..43a1e501b8 100644
--- a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt
+++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentDetachedTest.kt
@@ -25,13 +25,14 @@ import org.robolectric.RobolectricTestRunner
/**
* Regression test for ADFA-3472.
*
- * [BuildOutputFragment.clearOutput] and [BuildOutputFragment.getShareableContent] touch
+ * [BuildOutputFragment.clearOutput], [BuildOutputFragment.getShareableContent], and
+ * [BuildOutputFragment.appendOutput] touch
* `buildOutputViewModel`, which is created via `by activityViewModels()`. Forcing that lazy
* delegate while the fragment is detached calls `requireActivity()`, which throws an
* [IllegalStateException] ("not attached to an activity"). The run-tasks dialog / config-change
* path can invoke these methods on a detached fragment, crashing the app (Sentry ADFA-3472).
*
- * The fix guards both methods with `if (!isAdded || activity == null) return`. These tests
+ * The fix guards all three methods with `if (!isAdded || activity == null) return`. These tests
* assert that a detached fragment does NOT crash and returns the safe no-op values.
*
* Mutation-mindset: on the pre-fix code (no guard), both calls force the activityViewModels
@@ -39,34 +40,43 @@ import org.robolectric.RobolectricTestRunner
*/
@RunWith(RobolectricTestRunner::class)
class BuildOutputFragmentDetachedTest {
+ /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */
+ @Test
+ fun `clearOutput on a detached fragment does not crash`() {
+ // A freshly-constructed fragment that was never added to an activity is "detached":
+ // isAdded == false and activity == null, exactly the run-tasks / config-change state
+ // in which the Sentry crash was observed.
+ val fragment = BuildOutputFragment()
- /** Verifies clearOutput() is a safe no-op on a detached fragment instead of crashing. */
- @Test
- fun `clearOutput on a detached fragment does not crash`() {
- // A freshly-constructed fragment that was never added to an activity is "detached":
- // isAdded == false and activity == null, exactly the run-tasks / config-change state
- // in which the Sentry crash was observed.
- val fragment = BuildOutputFragment()
+ assertThat(fragment.isAdded).isFalse()
- assertThat(fragment.isAdded).isFalse()
+ // Pre-fix: this forces the `by activityViewModels()` delegate, which calls
+ // requireActivity() on a detached fragment and throws IllegalStateException.
+ // Post-fix: the guard returns early, no exception.
+ fragment.clearOutput()
+ }
- // Pre-fix: this forces the `by activityViewModels()` delegate, which calls
- // requireActivity() on a detached fragment and throws IllegalStateException.
- // Post-fix: the guard returns early, no exception.
- fragment.clearOutput()
- }
+ /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */
+ @Test
+ fun `getShareableContent on a detached fragment returns empty without crashing`() {
+ val fragment = BuildOutputFragment()
- /** Verifies getShareableContent() returns an empty string on a detached fragment instead of crashing. */
- @Test
- fun `getShareableContent on a detached fragment returns empty without crashing`() {
- val fragment = BuildOutputFragment()
+ assertThat(fragment.isAdded).isFalse()
- assertThat(fragment.isAdded).isFalse()
+ // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE.
+ // Post-fix: guard returns "" without touching the view model.
+ val content = fragment.getShareableContent()
- // Pre-fix: forces the activityViewModels delegate -> requireActivity() -> ISE.
- // Post-fix: guard returns "" without touching the view model.
- val content = fragment.getShareableContent()
+ assertThat(content).isEmpty()
+ }
- assertThat(content).isEmpty()
- }
+ /** Verifies appendOutput() is a safe no-op on a detached fragment instead of crashing. */
+ @Test
+ fun `appendOutput on a detached fragment does not crash`() {
+ val fragment = BuildOutputFragment()
+
+ assertThat(fragment.isAdded).isFalse()
+
+ fragment.appendOutput("build output")
+ }
}
diff --git a/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt
new file mode 100644
index 0000000000..c7cf77898e
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputFragmentTest.kt
@@ -0,0 +1,87 @@
+/*
+ * This file is part of AndroidIDE.
+ *
+ * AndroidIDE is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * AndroidIDE is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with AndroidIDE. If not, see .
+ */
+
+package com.itsaky.androidide.fragments.output
+
+import android.view.View
+import androidx.fragment.app.FragmentActivity
+import androidx.lifecycle.ViewModelProvider
+import com.google.common.truth.Truth.assertThat
+import com.itsaky.androidide.R
+import com.itsaky.androidide.app.BaseApplication
+import com.itsaky.androidide.viewmodel.BuildOutputViewModel
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.StandardTestDispatcher
+import kotlinx.coroutines.test.resetMain
+import kotlinx.coroutines.test.runTest
+import kotlinx.coroutines.test.setMain
+import org.junit.After
+import org.junit.Before
+import org.junit.Test
+import org.junit.runner.RunWith
+import org.robolectric.Robolectric
+import org.robolectric.RobolectricTestRunner
+import org.robolectric.annotation.Config
+
+@OptIn(ExperimentalCoroutinesApi::class)
+@RunWith(RobolectricTestRunner::class)
+@Config(application = BuildOutputFragmentTest.TestApp::class)
+class BuildOutputFragmentTest {
+ open class TestApp : BaseApplication()
+
+ private val testDispatcher = StandardTestDispatcher()
+
+ @Before
+ fun setUp() {
+ Dispatchers.setMain(testDispatcher)
+ }
+
+ @After
+ fun tearDown() {
+ Dispatchers.resetMain()
+ }
+
+ @Test
+ fun `offscreen batch times out without blocking later editor work`() =
+ runTest(testDispatcher) {
+ val controller = Robolectric.buildActivity(FragmentActivity::class.java)
+ controller.get().setTheme(R.style.Theme_AndroidIDE)
+ val activity = controller.setup().get()
+ try {
+ val fragment = BuildOutputFragment()
+ activity.supportFragmentManager
+ .beginTransaction()
+ .add(android.R.id.content, fragment)
+ .commitNow()
+ testDispatcher.scheduler.advanceUntilIdle()
+
+ val editor = checkNotNull(fragment.editor)
+ editor.visibility = View.GONE
+ editor.layout(0, 0, 0, 0)
+ val viewModel = ViewModelProvider(activity)[BuildOutputViewModel::class.java]
+ val sessionToken = viewModel.currentSessionToken
+
+ fragment.flushToEditor("first\n", 6, 0, sessionToken, 0)
+ fragment.flushToEditor("second\n", 7, 0, sessionToken, 0)
+
+ assertThat(editor.text.toString()).isEmpty()
+ } finally {
+ controller.destroy()
+ }
+ }
+}
diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml
index 7ff3bc7f18..f215d7e439 100644
--- a/resources/src/main/res/values/strings.xml
+++ b/resources/src/main/res/values/strings.xml
@@ -687,6 +687,10 @@
Open a file to show its diagnostic results
Filter lines
No matching log entries found.
+
+ - [%1$d build output line omitted]
+ - [%1$d build output lines omitted]
+
No search matches found.
Filter
Search