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..934e92bd6e
--- /dev/null
+++ b/app/src/main/java/com/itsaky/androidide/fragments/output/BuildOutputBuffer.kt
@@ -0,0 +1,165 @@
+/*
+ * 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,
+ )
+
+ 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
+ while (entries.isNotEmpty()) {
+ val entry = entries.first()
+ if (entry.sessionToken != sessionToken) break
+ val value =
+ when (entry) {
+ is Entry.Text -> entry.value
+ is Entry.Omission -> omissionMarker(entry.lineCount)
+ }
+ if (batch.isNotEmpty() && batch.length + value.length > maxBatchChars) break
+
+ entries.removeFirst()
+ batch.append(value)
+ when (entry) {
+ is Entry.Text -> {
+ retainedChars -= entry.value.length
+ sourceChars = saturatedAdd(sourceChars, entry.value.length)
+ }
+ is Entry.Omission -> sourceChars = saturatedAdd(sourceChars, entry.sourceChars)
+ }
+ }
+ if (entries.isNotEmpty()) available.trySend(Unit)
+ return Batch(batch.toString(), sessionToken, sourceChars)
+ }
+
+ 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()
+
+ private fun omissionMarker(lineCount: Long): String {
+ val noun = if (lineCount == 1L) "line" else "lines"
+ return "[$lineCount build output $noun omitted]\n"
+ }
+
+ 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..c0eb0a6506 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,9 @@ class BuildOutputFragment :
setupSearchLayout()
viewLifecycleOwner.lifecycleScope.launch {
- launch { restoreWindowFromViewModel() }
- launch(Dispatchers.Default) { processLogs() }
launch {
- val content = buildOutputViewModel.getFullContent()
- buildOutputViewModel.setCachedSnapshot(content)
+ restoreWindowFromViewModel()
+ withContext(Dispatchers.Default) { processLogs() }
}
launch {
combine(
@@ -121,15 +116,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 +290,72 @@ 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) {
+ updateEmptyState(isSourceEmpty = isSourceEmpty, isFilterActive = isFilterActive)
+ noMatchTracker.prime(isFilteredEmpty)
+ if (!isSourceEmpty && isFilteredEmpty) {
+ editorContentMutex.withLock {
+ if (isRestoreCurrent()) {
+ 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) {
+ if (content.isEmpty()) return@withContext
+ 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) {
+ editorContentMutex.withLock {
+ if (isRestoreCurrent() && editor.appendBatchIfReady(content)) {
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ }
+ }
+ } else {
+ // Layout timed out; keep waiting so the restored content is not lost.
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)
- }
- }
+ editorContentMutex.withLock {
+ if (isRestoreCurrent() && editor.appendBatchIfReady(content)) {
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(window.length)
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
- job.join()
+ }
}
}
- }
override fun onDestroyView() {
searchLayout = null
filterBar = null
+ editorContentGeneration++
+ editorSourceChars = 0
editor?.release()
super.onDestroyView()
}
@@ -351,13 +365,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 +386,159 @@ class BuildOutputFragment :
}
fun appendOutput(output: String?) {
- if (!output.isNullOrEmpty()) {
- logChannel.trySend(output)
+ 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.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(
text: String,
- sessionGen: Int,
+ sourceChars: Int,
+ 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
+ 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) {
+ withContext(Dispatchers.Main) {
+ editorContentMutex.withLock {
+ if (
+ editorGen != editorContentGeneration ||
+ !buildOutputViewModel.isCurrentSession(sessionToken)
+ ) {
+ return@withLock
+ }
+ val editor = editor ?: return@withLock
updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
+ if (refreshedWindow != null) {
+ editorContentGeneration++
+ editor.setText(refreshedWindow.first)
+ editorSourceChars =
+ BuildOutputViewModel.editorSourceCharsAfterRefresh(refreshedWindow.second)
+ onContentReplaced()
+ return@withLock
+ }
if (visibleText.isEmpty()) {
- return@withContext
+ editorSourceChars += sourceChars
+ return@withLock
}
- 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)
- }
- }
- }
- }
+
+ val layoutCompleted =
+ withTimeoutOrNull(LAYOUT_TIMEOUT_MS) {
+ editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
+ }
+ if (layoutCompleted != null) {
+ if (editor.appendBatchIfReady(visibleText)) {
+ editorSourceChars += sourceChars
+ }
+ } else {
+ editor.awaitLayout(onForceVisible = { updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive) })
+ if (
+ editorGen == editorContentGeneration &&
+ buildOutputViewModel.isCurrentSession(sessionToken) &&
+ editor.appendBatchIfReady(visibleText)
+ ) {
+ editorSourceChars += sourceChars
+ updateEmptyState(isSourceEmpty = false, isFilterActive = isFilterActive)
}
}
}
}
}
+
+ 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..f3b89f21d4 100644
--- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt
+++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt
@@ -47,6 +47,16 @@ class BuildOutputViewModel(
) : AndroidViewModel(application) {
private val lock = ReentrantLock()
+ @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.
* The session file always receives the unfiltered text.
@@ -88,32 +98,38 @@ 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)
+ true
} catch (e: Exception) {
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,6 +175,7 @@ class BuildOutputViewModel(
*/
fun clear() {
lock.withLock {
+ sessionGeneration++
cachedContentSnapshot = ""
try {
if (sessionFile.exists()) {
@@ -194,6 +211,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 +291,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..30ef509ab2
--- /dev/null
+++ b/app/src/test/java/com/itsaky/androidide/fragments/output/BuildOutputBufferTest.kt
@@ -0,0 +1,205 @@
+/*
+ * 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("[2 build output lines omitted]\nthree\nfour\n")
+ 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("[1 build output line omitted]\n3456789\n")
+ 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 `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")
+ }
+}