From 92f37733c83e311371c409b77985a41084ea13e8 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Wed, 26 Aug 2026 16:32:06 -0500 Subject: [PATCH 1/2] fix(build-output): give the agent the real build log - read_build_output returned a canned status naming a nonexistent build_app tool; it now serves live bottom-sheet text, else the session-file tail, else null. - Writes move to a fragment-independent async sink, so a build started from the chat tab still lands in the session file. - run_app wiring extracted from CredentialProtectedApplicationLoader into PluginRunAppCoordinator. --- .../androidide/api/BuildOutputProvider.kt | 100 +++++++++- .../CredentialProtectedApplicationLoader.kt | 63 +------ .../androidide/app/PluginRunAppCoordinator.kt | 135 ++++++++++++++ .../fragments/output/BuildOutputFragment.kt | 7 +- .../itsaky/androidide/ui/EditorBottomSheet.kt | 17 +- .../viewmodel/BuildOutputViewModel.kt | 173 +++++++++++++++--- .../androidide/viewmodel/BuildViewModel.kt | 40 +++- .../androidide/api/BuildOutputProviderTest.kt | 165 +++++++++++++++++ .../viewmodel/BuildOutputSessionSinkTest.kt | 133 ++++++++++++++ 9 files changed, 739 insertions(+), 94 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt create mode 100644 app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputSessionSinkTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt b/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt index e653989150..02d65e2c53 100644 --- a/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt @@ -1,6 +1,15 @@ package com.itsaky.androidide.api +import android.os.Looper +import androidx.annotation.VisibleForTesting +import com.itsaky.androidide.app.IDEApplication import com.itsaky.androidide.ui.EditorBottomSheet +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeoutOrNull +import java.io.File import java.lang.ref.WeakReference /** @@ -8,8 +17,15 @@ import java.lang.ref.WeakReference * This acts as a service locator to avoid memory leaks. */ object BuildOutputProvider { + // Both fields are written from the main thread (or a test) and read from the background thread + // a plugin calls in on, so the reader needs the write to be visible. + @Volatile private var bottomSheetRef: WeakReference? = null + /** Session-file directory override for unit tests, which have no [IDEApplication]. */ + @Volatile + private var sessionDirOverride: File? = null + fun setBottomSheet(sheet: EditorBottomSheet) { this.bottomSheetRef = WeakReference(sheet) } @@ -19,8 +35,88 @@ object BuildOutputProvider { this.bottomSheetRef = null } + /** + * Returns the build output for consumers outside the editor UI (the AI plugins' + * `read_build_output`), or `null` when there is none. Never returns a status message: the caller + * cannot tell one from a log, and a plausible non-empty answer is worse than nothing. + * + * Reads the live bottom-sheet content first, then the session file on disk. The fallback must + * trigger on blank, not just null: [com.itsaky.androidide.fragments.output.BuildOutputFragment.getShareableContent] + * returns `""` while detached, and the file is still there after a crashed build -- exactly when + * the log matters most. + * + * Line timing prefixes are stripped; ~22 characters a line of clock time no agent can use. + * Does disk I/O, so call off the main thread. + */ fun getBuildOutputContent(): String? { - val bottomSheet = bottomSheetRef?.get() ?: return null - return bottomSheet.pagerAdapter.buildOutputFragment?.getShareableContent() + val content = liveContent() ?: sessionFileTail() ?: return null + return BuildOutputViewModel + .filterLines( + content = BuildOutputViewModel.tailFromLineStart(content, WINDOW_MAX_CHARS), + query = "", + showTimestamps = false, + showDeltas = false, + ).takeIf { it.isNotBlank() } + } + + /** Sets the directory holding the session file. Test seam for the [sessionFileTail] fallback. */ + @VisibleForTesting + internal fun setSessionDirectoryForTest(dir: File?) { + sessionDirOverride = dir + } + + /** + * The bottom sheet's own view of the output, read on the main thread. + * + * The fragment's lifecycle state and its lazily resolved view model are main-thread-only, and a + * plugin calls in from a background thread, so the read is dispatched there and bounded by + * [LIVE_READ_TIMEOUT_MS]. Exceeding that is not an error: the caller falls through to the + * session file, which holds the same log one flush behind. + */ + private fun liveContent(): String? = + runCatching { + if (isMainThread()) { + sheetContent() + } else { + runBlocking { + withTimeoutOrNull(LIVE_READ_TIMEOUT_MS) { + withContext(Dispatchers.Main) { sheetContent() } + } + } + } + }.getOrNull()?.takeIf { it.isNotBlank() } + + /** + * Whether the caller is already on the main thread. Also true under a JVM unit test, where + * [Looper] is not mocked and throws: there is no main thread to dispatch to, and the read is + * safe on the test thread. + */ + private fun isMainThread(): Boolean = runCatching { Looper.myLooper() == Looper.getMainLooper() }.getOrDefault(true) + + /** Main thread only; see [liveContent]. */ + private fun sheetContent(): String? = + bottomSheetRef + ?.get() + ?.pagerAdapter + ?.buildOutputFragment + ?.getShareableContent() + + private fun sessionFileTail(): String? { + val dir = sessionDirOverride ?: runCatching { IDEApplication.instance.cacheDir }.getOrNull() ?: return null + // Read without BuildOutputViewModel's lock: a concurrent append can leave the window starting + // mid-UTF-8-sequence, which decodes to a single U+FFFD rather than throwing. + return BuildOutputViewModel + .readTailFromFile(File(dir, BuildOutputViewModel.SESSION_FILE_NAME), WINDOW_MAX_CHARS) + .takeIf { it.isNotBlank() } } + + /** + * Upper bound on the returned window. Consumers window this down further (the agent tool takes + * 8000 characters); this only keeps a whole session out of a single string. + */ + @VisibleForTesting + internal const val WINDOW_MAX_CHARS = 128 * 1024 + + /** How long the live read may hold the calling thread before the session file takes over. */ + private const val LIVE_READ_TIMEOUT_MS = 500L } diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index 7af94d9e9b..45e4ad530c 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -10,6 +10,7 @@ import androidx.work.WorkManager import com.google.android.material.color.DynamicColors import com.itsaky.androidide.activities.CrashHandlerActivity import com.itsaky.androidide.activities.editor.IDELogcatReader +import com.itsaky.androidide.api.BuildOutputProvider import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent @@ -394,49 +395,8 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { // Provide runApp functionality buildServiceImpl.setRunAppProvider { callback -> - logger.info("runApp provider called - attempting to get BuildService from Lookup") - GlobalScope.launch(Dispatchers.IO) { - try { - val buildService = Lookup.getDefault().lookup(com.itsaky.androidide.projects.builder.BuildService.KEY_BUILD_SERVICE) - logger.info("BuildService lookup result: {}", if (buildService == null) "NULL" else "FOUND") - if (buildService == null) { - callback.onComplete(false, "Build service not available - may need to open a project first") - return@launch - } - - val projectManager = - com.itsaky.androidide.projects.IProjectManager - .getInstance() - val appModules = projectManager.getAndroidAppModules() - - if (appModules.isEmpty()) { - callback.onComplete(false, "No Android app modules found in project") - return@launch - } - - val module = appModules.firstOrNull() - val variant = module?.getSelectedVariant() - - if (module == null || variant == null) { - callback.onComplete(false, "No app module or variant selected") - return@launch - } - - val taskName = "${module.path}:${variant.mainArtifact.assembleTaskName}" - val result = buildService.executeTasks(tasks = listOf(taskName)).get() - - if (result == null || !result.isSuccessful) { - callback.onComplete(false, "Build failed: ${result?.failure}") - return@launch - } - - // TODO: Install and launch APK - callback.onComplete(true, "Build successful (installation not yet implemented)") - } catch (e: Exception) { - logger.error("Failed to run app", e) - callback.onComplete(false, "Error: ${e.message}") - } - } + logger.info("runApp provider called") + PluginRunAppCoordinator.runApp(application.foregroundActivity, callback) } // Provide gradle sync functionality @@ -467,19 +427,16 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { } } - // Provide build output + // Provide build output: the real log, or null when there is none. No status text -- the + // consumer cannot tell a message from a log, so anything non-empty reads as build output. + // No BuildService lookup either: the session file outlives the tooling server, and reading it + // after a crashed build is precisely when the log is needed. buildServiceImpl.setBuildOutputProvider { try { - val buildService = Lookup.getDefault().lookup(com.itsaky.androidide.projects.builder.BuildService.KEY_BUILD_SERVICE) - if (buildService != null) { - // Try to get build output from the service - // Note: BuildService doesn't directly expose output, so we return last build status - "Build service is available. Run build_app or gradle_sync to see output." - } else { - "Build service not available" - } + BuildOutputProvider.getBuildOutputContent() } catch (e: Exception) { - "Error getting build output: ${e.message}" + logger.error("Failed to read build output", e) + null } } } diff --git a/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt b/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt new file mode 100644 index 0000000000..6079aed9d1 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt @@ -0,0 +1,135 @@ +package com.itsaky.androidide.app + +import android.app.Activity +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.lifecycleScope +import com.itsaky.androidide.activities.editor.ProjectHandlerActivity +import com.itsaky.androidide.interfaces.IEditorHandler +import com.itsaky.androidide.plugins.services.BuildAndLaunchCallback +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.projects.isPluginProject +import com.itsaky.androidide.viewmodel.BuildState +import com.itsaky.androidide.viewmodel.BuildViewModel +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.slf4j.LoggerFactory +import java.util.concurrent.atomic.AtomicBoolean + +/** + * Runs the current project's app on behalf of a plugin. + * + * Delegates to [BuildViewModel.runQuickBuild], the same entry point as the editor's Run action, so + * a plugin gets APK resolution, the install and the launch prompt rather than a bare assemble. + * That path is activity-scoped, which is why this needs the foreground activity and the main + * thread; a plugin calls in from a background coroutine. + */ +internal object PluginRunAppCoordinator { + private val logger = LoggerFactory.getLogger(PluginRunAppCoordinator::class.java) + + /** + * Builds, installs and launches the selected app module. + * + * @param foregroundActivity the activity currently on screen, or null when there is none. + * @param callback completed once, when the build resolves or the run is abandoned. Success + * means the installer has the APK, not that the app is on screen: the system install prompt + * and the launch prompt are the user's to answer. + */ + fun runApp( + foregroundActivity: Activity?, + callback: BuildAndLaunchCallback, + ) { + val activity = foregroundActivity as? ProjectHandlerActivity + if (activity == null) { + callback.onComplete(false, "No project is open in the editor. Open one before running the app.") + return + } + + val reported = AtomicBoolean(false) + + fun report( + success: Boolean, + message: String, + ) { + if (reported.compareAndSet(false, true)) { + callback.onComplete(success, message) + } + } + + activity.lifecycleScope.launch { + try { + startBuild(activity, ::report) + } catch (e: CancellationException) { + // The editor was destroyed before the build was even handed off. Nothing will + // report later, so the caller has to hear it here or wait out its own timeout. + report(false, "The editor was closed before the build started.") + throw e + } catch (e: Exception) { + logger.error("Failed to run the app for a plugin", e) + report(false, "Error: ${e.message}") + } + } + } + + /** + * Resolves what to build and starts it. Returns as soon as the build is under way: the outcome + * arrives on [BuildViewModel.runQuickBuild]'s callback, which outlives this coroutine. + */ + private suspend fun startBuild( + activity: ProjectHandlerActivity, + report: (Boolean, String) -> Unit, + ) { + val projectManager = IProjectManager.getInstance() + val isPluginProject = withContext(Dispatchers.IO) { projectManager.isPluginProject() } + val module = + if (isPluginProject) { + projectManager.getAndroidModules().firstOrNull() + } else { + // The Run action asks the user which app module to build; a plugin has no one to + // ask, so it gets the first, as the previous provider did. + projectManager.getAndroidAppModules().firstOrNull() + } + val variant = module?.getSelectedVariant() + if (module == null || variant == null) { + report(false, "No app module or build variant is selected.") + return + } + + val buildViewModel = ViewModelProvider(activity)[BuildViewModel::class.java] + (activity as? IEditorHandler)?.saveAllResult() + buildViewModel.runQuickBuild(module, variant, launchInDebugMode = false) { state -> + val (success, message) = state.toOutcome() + report(success, message) + } + } + + /** What to tell the plugin about a run that ended on [this]. */ + private fun BuildState.toOutcome(): Pair = + when (this) { + is BuildState.AwaitingInstall -> { + true to "Build succeeded. Installing ${apkFile.name} - confirm the system prompt to launch the app." + } + + is BuildState.AwaitingPluginInstall -> { + true to "Build succeeded. Installing plugin ${cgpFile.name} - confirm the prompt in the IDE." + } + + is BuildState.Error -> { + false to reason + } + + is BuildState.Success -> { + true to message + } + + // runQuickBuild only ends on Idle when its scope was cancelled, and never on InProgress. + BuildState.Idle -> { + false to "The build was cancelled." + } + + BuildState.InProgress -> { + false to "The build did not report a result." + } + } +} 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..d0202acf78 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 @@ -440,12 +440,11 @@ class BuildOutputFragment : editorGen: Int, ) { editorContentMutex.withLock { - // A clear (new build) after this batch was drained invalidates session append. + // A clear (new build) after this batch was drained invalidates it. if (sessionGen != sessionGeneration) return - buildOutputViewModel.append(text) - - // The session file always gets the full text; the editor only shows matching lines + // Display only. The session file is written by EditorBottomSheet.appendBuildOut, which + // sees every line whether or not this fragment exists; the editor shows matching lines. val visibleText = BuildOutputViewModel.filterLines( text, diff --git a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt index 7bcce2b991..9570333eb2 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/EditorBottomSheet.kt @@ -512,8 +512,17 @@ class EditorBottomSheet pagerAdapter.logFragment?.appendLog(line) } + /** + * Records a line of build output and shows it if the Build Output tab is on screen. + * + * The view model is written first and unconditionally: the pager destroys + * [com.itsaky.androidide.fragments.output.BuildOutputFragment] whenever another tab is + * shown, so while the fragment was the only writer, a build started from the AI agent's tab + * -- the one place the user necessarily is when the agent builds -- left no log behind. + */ fun appendBuildOut(str: String?) { - if (str != null && shouldFilter(str)) return + if (str == null || shouldFilter(str)) return + buildOutputViewModel.appendAsync(str) pagerAdapter.buildOutputFragment?.appendOutput(str) } @@ -532,7 +541,13 @@ class EditorBottomSheet private fun shouldFilter(msg: String): Boolean = suppressedGradleWarnings.any { msg.contains(it) } + /** + * Starts a new build output session. Clears the view model whether or not the tab exists -- + * otherwise a build run from another tab appends to the previous build's log, and the agent + * reads an error the current build never produced. + */ fun clearBuildOutput() { + buildOutputViewModel.clear() pagerAdapter.buildOutputFragment?.takeIf { it.isAdded }?.clearOutput() } 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..c374b53232 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildOutputViewModel.kt @@ -18,9 +18,12 @@ package com.itsaky.androidide.viewmodel import android.app.Application import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope import com.itsaky.androidide.preferences.internal.EditorPreferences import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import java.io.File import java.io.FileOutputStream @@ -40,7 +43,11 @@ import kotlin.math.max * content is read from file on demand for share/API. Memory is bounded by not holding the full * log in RAM. * - * Append/clear are intended to be called from the main thread (from [BuildOutputFragment]). + * [appendAsync] is the write path and is safe to call from any thread; it does not depend on the + * Build Output tab existing. That matters because the tab lives in a pager that destroys its + * fragment whenever another tab is shown -- the AI agent's chat tab included -- and while the + * fragment was the only caller of [append], a build started from the chat wrote no log at all and + * the agent's `read_build_output` had nothing to read. */ class BuildOutputViewModel( application: Application, @@ -86,14 +93,98 @@ class BuildOutputViewModel( private val sessionFile: File get() = File(getApplication().cacheDir, SESSION_FILE_NAME) + /** + * Output waiting to be written. Unbounded and non-blocking to send: [appendAsync] is called from + * the Gradle tooling thread for every line of a build, which must never wait on disk. + */ + private val pendingOutput = Channel(Channel.UNLIMITED) + + /** + * Bumped by [clear]. A batch already drained when a new build starts belongs to the old session, + * and writing it would put the previous build's errors in front of the current build's. + */ + @Volatile + private var sessionGeneration = 0 + + init { + viewModelScope.launch(Dispatchers.Default) { writePendingOutput() } + } + + /** + * Queues [text] for the session file. Returns immediately; safe from any thread. + * + * @param text one line, or several, of build output; a missing trailing newline is added. + */ + fun appendAsync(text: String) { + if (text.isEmpty()) return + // Stamped here, not at drain time: a clear() between the queue handing an item to the writer + // and the writer reading the counter would file the finished build's output under the new + // session. The producer's moment is the one that decides which build the text belongs to. + pendingOutput.trySend( + PendingOutput(sessionGeneration, if (text.endsWith('\n')) text else text + "\n"), + ) + } + + /** + * Drains [pendingOutput] for as long as the view model lives, batching whatever has piled up + * into one write: a large build emits thousands of lines, and one file open per line is the + * difference between a background write and a stutter. + */ + private suspend fun writePendingOutput() { + val batch = StringBuilder() + for (first in pendingOutput) { + var generation = first.generation + batch.append(first.text) + while (true) { + val next = pendingOutput.tryReceive().getOrNull() ?: break + // A batch spans one session only, so a clear() mid-drain flushes what came before it. + if (next.generation != generation) { + appendForSession(batch.toString(), generation) + batch.setLength(0) + generation = next.generation + } + batch.append(next.text) + } + appendForSession(batch.toString(), generation) + batch.setLength(0) + } + } + + /** + * One queued piece of build output. + * + * @property generation the session it was produced in; see [sessionGeneration]. + * @property text the output, newline-terminated. + */ + private data class PendingOutput( + val generation: Int, + val text: String, + ) + /** * 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. */ - suspend fun append(text: String) { + suspend fun append(text: String) = appendForSession(text, sessionGeneration) + + /** + * Appends [text] only while [generation] is still the current session. + * + * The check lives inside the lock, with the write: checked outside, a batch that had already + * passed it could still reach the disk after [clear] had deleted the file, seeding the new + * build's log with the finished build's errors. + * + * @param text the output to write. + * @param generation the session the text was produced in. + */ + private suspend fun appendForSession( + text: String, + generation: Int, + ) { if (text.isEmpty()) return withContext(Dispatchers.IO) { lock.withLock { + if (generation != sessionGeneration) return@withLock try { FileOutputStream(sessionFile, true).use { it.write(text.toByteArray(StandardCharsets.UTF_8)) @@ -159,6 +250,12 @@ class BuildOutputViewModel( */ fun clear() { lock.withLock { + // Queued text is the finished build's; dropping it here, and bumping the generation for + // the batch that may already be in flight, keeps the two sessions out of one file. + sessionGeneration++ + while (pendingOutput.tryReceive().isSuccess) { + // Discarded: this text belongs to the session being cleared. + } cachedContentSnapshot = "" try { if (sessionFile.exists()) { @@ -170,29 +267,6 @@ class BuildOutputViewModel( } } - private fun readTailFromFile( - file: File, - maxChars: Int, - ): String { - if (!file.exists()) return "" - try { - RandomAccessFile(file, "r").use { raf -> - val len = raf.length() - if (len == 0L) return "" - // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars - val maxBytes = minOf(len, maxChars * 4L) - raf.seek(max(0, len - maxBytes)) - val bytes = ByteArray(maxBytes.toInt()) - raf.readFully(bytes) - val decoded = String(bytes, Charsets.UTF_8) - return if (decoded.length <= maxChars) decoded else decoded.takeLast(maxChars) - } - } catch (e: Exception) { - log.error("Failed to read tail from build output session file", e) - return "" - } - } - companion object { // 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. @@ -260,7 +334,54 @@ class BuildOutputViewModel( } } - private const val SESSION_FILE_NAME = "build_output_session.txt" + /** + * The last [maxChars] characters of [text], started at a line boundary. + * + * A tail sliced at a character offset begins part-way through a line, and [PREFIX_REGEX] is + * anchored to the start of one, so that fragment keeps the timestamp every other line has + * stripped. Text short enough to survive whole keeps its real first line; a tail holding no + * newline at all is returned as it is, being better than nothing. + */ + internal fun tailFromLineStart( + text: String, + maxChars: Int, + ): String { + if (text.length <= maxChars) return text + val tail = text.takeLast(maxChars) + val newline = tail.indexOf('\n') + return if (newline == -1) tail else tail.substring(newline + 1) + } + + /** + * Reads the last [maxChars] characters of [file], or `""` when it is missing or unreadable. + * Shared with [com.itsaky.androidide.api.BuildOutputProvider], which reads the same session + * file for consumers outside the editor UI. + */ + internal fun readTailFromFile( + file: File, + maxChars: Int, + ): String { + if (!file.exists()) return "" + try { + RandomAccessFile(file, "r").use { raf -> + val len = raf.length() + if (len == 0L) return "" + // UTF-8: up to 4 bytes per char; read enough bytes for maxChars, then decode and take last maxChars + val maxBytes = minOf(len, maxChars * 4L) + raf.seek(max(0, len - maxBytes)) + val bytes = ByteArray(maxBytes.toInt()) + raf.readFully(bytes) + val decoded = String(bytes, Charsets.UTF_8) + return tailFromLineStart(decoded, maxChars) + } + } catch (e: Exception) { + log.error("Failed to read tail from build output session file", e) + return "" + } + } + + /** Name of the on-disk build output session file, shared with [com.itsaky.androidide.api.BuildOutputProvider]. */ + internal const val SESSION_FILE_NAME = "build_output_session.txt" private const val WINDOW_SIZE_CHARS = 512 * 1024 /** Max length of [cachedContentSnapshot] to bound memory. */ diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 5c220f86c8..700b5ebf9d 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -30,24 +30,46 @@ class BuildViewModel : ViewModel() { private val _buildState = MutableStateFlow(BuildState.Idle) val buildState: StateFlow = _buildState + /** + * Builds the selected variant and hands the result to the installer. + * + * @param onTerminalState invoked exactly once with the state the run ends on. [buildState] is + * a conflated flow whose terminal values are transient — the editor resets `AwaitingInstall` + * to `Idle` the moment it takes the APK — so a caller that must not miss the outcome (a + * plugin waiting on a callback) has to be told directly rather than observe the flow. + */ fun runQuickBuild( module: AndroidModule, variant: AndroidModels.AndroidVariant, launchInDebugMode: Boolean, launchProfilerAfterInstall: Boolean = false, gradleArgs: List = emptyList(), + onTerminalState: ((BuildState) -> Unit)? = null, ) { if (_buildState.value is BuildState.InProgress) { log.warn("Build is already in progress. Ignoring new request.") + onTerminalState?.invoke(BuildState.Error("A build is already in progress.")) return } viewModelScope.launch { + var reported = false + + // Publishes a terminal state and notifies the caller once, from the one place that + // knows the run is over. Called only on the main dispatcher, so the flag needs no lock. + fun finish(state: BuildState) { + _buildState.value = state + if (!reported) { + reported = true + onTerminalState?.invoke(state) + } + } + _buildState.value = BuildState.InProgress val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (buildService == null) { - _buildState.value = BuildState.Error("Build service not found.") + finish(BuildState.Error("Build service not found.")) return@launch } @@ -89,11 +111,12 @@ class BuildViewModel : ViewModel() { val cgpFile = withContext(Dispatchers.IO) { findPluginCgpFile(projectRoot, variant) } if (cgpFile != null) { - _buildState.value = BuildState.AwaitingPluginInstall(cgpFile) + finish(BuildState.AwaitingPluginInstall(cgpFile)) } else { log.warn("Plugin built successfully but .cgp file not found") - _buildState.value = - BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin") + finish( + BuildState.Error("Plugin built but output file (.cgp) not found in build/plugin"), + ) } return@launch } @@ -110,19 +133,20 @@ class BuildViewModel : ViewModel() { throw RuntimeException("APK file specified does not exist: $apkFile") } - _buildState.value = + finish( BuildState.AwaitingInstall( apkFile, launchInDebugMode, launchProfilerAfterInstall = launchProfilerAfterInstall, - ) + ), + ) } catch (e: Exception) { if (e is CancellationException) { log.info("Build was cancelled by the user.") - _buildState.value = BuildState.Idle + finish(BuildState.Idle) } else { log.error("Quick Run failed.", e) - _buildState.value = BuildState.Error(e.message ?: "An unknown error occurred.") + finish(BuildState.Error(e.message ?: "An unknown error occurred.")) } } } diff --git a/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderTest.kt b/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderTest.kt new file mode 100644 index 0000000000..5d29efe669 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderTest.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.api + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.adapters.EditorBottomSheetTabAdapter +import com.itsaky.androidide.fragments.output.BuildOutputFragment +import com.itsaky.androidide.ui.EditorBottomSheet +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import io.mockk.every +import io.mockk.mockk +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import java.io.File + +/** + * Covers the session-file fallback [BuildOutputProvider.getBuildOutputContent] gained for + * ADFA-5216. Most cases set no bottom sheet, which is the state the AI plugins read in: the tab may + * never have been materialised, and the log matters most after a build that killed the service. The + * live-content cases cover the other half -- a sheet that exists but answers blank while detached. + */ +class BuildOutputProviderTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @After + fun tearDown() { + BuildOutputProvider.setSessionDirectoryForTest(null) + BuildOutputProvider.clearBottomSheet() + } + + @Test + fun `givenSessionFile_whenNoBottomSheet_thenReturnsItsContent`() { + writeSessionFile("> Task :app:compileV8DebugKotlin\ne: Foo.kt:12:5 unresolved reference: bar\n") + + assertThat(BuildOutputProvider.getBuildOutputContent()) + .isEqualTo("> Task :app:compileV8DebugKotlin\ne: Foo.kt:12:5 unresolved reference: bar\n") + } + + @Test + fun `givenNoSessionFile_whenReadingOutput_thenReturnsNull`() { + BuildOutputProvider.setSessionDirectoryForTest(tempFolder.root) + + // Null, not a status message: a non-empty answer is indistinguishable from a build log. + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + @Test + fun `givenEmptySessionFile_whenReadingOutput_thenReturnsNull`() { + writeSessionFile("") + + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + @Test + fun `givenBlankSessionFile_whenReadingOutput_thenReturnsNull`() { + writeSessionFile(" \n\n\t\n") + + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + @Test + fun `givenUnresolvableSessionDirectory_whenReadingOutput_thenReturnsNull`() { + // No override and no IDEApplication in a JVM test: the provider must not throw. + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + @Test + fun `givenTimingPrefixes_whenReadingOutput_thenTheyAreStripped`() { + val prefix = BuildOutputViewModel.formatLinePrefix(nowMs = 0L, stepDeltaMs = 12L) + writeSessionFile("${prefix}e: Foo.kt:12:5 unresolved reference: bar\n") + + assertThat(BuildOutputProvider.getBuildOutputContent()) + .isEqualTo("e: Foo.kt:12:5 unresolved reference: bar\n") + } + + @Test + fun `givenLogLongerThanTheWindow_whenReadingOutput_thenTheTailIsReturned`() { + val line = "> Task :app:someTaskWithAReasonablyLongName\n" + val repeats = (BuildOutputProvider.WINDOW_MAX_CHARS / line.length) + 100 + writeSessionFile(line.repeat(repeats) + "FAILURE: Build failed with an exception.\n") + + val output = BuildOutputProvider.getBuildOutputContent() + + assertThat(output).isNotNull() + assertThat(output!!.length).isAtMost(BuildOutputProvider.WINDOW_MAX_CHARS) + assertThat(output).contains("FAILURE: Build failed with an exception.") + } + + @Test + fun `givenLogLongerThanTheWindow_whenReadingOutput_thenItStartsOnAWholeLine`() { + // A tail sliced at a character offset leaves a fragment the start-anchored prefix regex + // cannot match, so half a timestamp survives into the agent's first line. + val prefix = BuildOutputViewModel.formatLinePrefix(nowMs = 0L, stepDeltaMs = 12L) + val line = "$prefix> Task :app:someTaskWithAReasonablyLongName\n" + val repeats = (BuildOutputProvider.WINDOW_MAX_CHARS / line.length) + 100 + writeSessionFile(line.repeat(repeats)) + + val output = BuildOutputProvider.getBuildOutputContent() + + assertThat(output).isNotNull() + assertThat(output!!.first()).isEqualTo('>') + // Every prefix was stripped, so no fragment of one is left anywhere. + assertThat(output).doesNotContain("]") + } + + @Test + fun `givenLiveContent_whenReadingOutput_thenTheSessionFileIsNotConsulted`() { + writeSessionFile("stale session file\n") + setLiveContent("> Task :app:compileV8DebugKotlin\n") + + assertThat(BuildOutputProvider.getBuildOutputContent()) + .isEqualTo("> Task :app:compileV8DebugKotlin\n") + } + + @Test + fun `givenBlankLiveContent_whenSessionFileExists_thenTheFileIsRead`() { + // getShareableContent() returns "" while the fragment is detached; blank must fall through. + writeSessionFile("e: Foo.kt:12:5 unresolved reference: bar\n") + setLiveContent(" \n\t\n") + + assertThat(BuildOutputProvider.getBuildOutputContent()) + .isEqualTo("e: Foo.kt:12:5 unresolved reference: bar\n") + } + + @Test + fun `givenBlankLiveContent_whenNoSessionFile_thenReturnsNull`() { + BuildOutputProvider.setSessionDirectoryForTest(tempFolder.root) + setLiveContent("") + + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + private fun setLiveContent(content: String) { + val fragment = mockk() + every { fragment.getShareableContent() } returns content + val adapter = mockk() + every { adapter.buildOutputFragment } returns fragment + val sheet = mockk() + every { sheet.pagerAdapter } returns adapter + BuildOutputProvider.setBottomSheet(sheet) + } + + private fun writeSessionFile(content: String) { + File(tempFolder.root, BuildOutputViewModel.SESSION_FILE_NAME).writeText(content) + BuildOutputProvider.setSessionDirectoryForTest(tempFolder.root) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputSessionSinkTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputSessionSinkTest.kt new file mode 100644 index 0000000000..c0d0f1b774 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildOutputSessionSinkTest.kt @@ -0,0 +1,133 @@ +/* + * 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.viewmodel + +import android.app.Application +import androidx.test.core.app.ApplicationProvider +import com.google.common.truth.Truth.assertThat +import kotlinx.coroutines.runBlocking +import org.junit.After +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import java.io.File + +/** + * Covers the write path that no longer depends on the Build Output tab existing. + * + * `BuildOutputFragment` used to be the only caller of [BuildOutputViewModel.append], and the pager + * destroys that fragment whenever another tab is shown. A build started from the AI agent's chat + * tab therefore wrote nothing to the session file, and `read_build_output` returned an empty log + * for a build that had just failed with compiler errors. + * + * Mutation-mindset: revert [BuildOutputViewModel.appendAsync] to a no-op, or take the queue-drop + * out of [BuildOutputViewModel.clear], and one of these goes red. + */ +@RunWith(RobolectricTestRunner::class) +class BuildOutputSessionSinkTest { + private lateinit var viewModel: BuildOutputViewModel + private lateinit var sessionFile: File + + @Before + fun setUp() { + val application = ApplicationProvider.getApplicationContext() + sessionFile = File(application.cacheDir, BuildOutputViewModel.SESSION_FILE_NAME) + sessionFile.delete() + viewModel = BuildOutputViewModel(application) + } + + @After + fun tearDown() { + sessionFile.delete() + } + + /** Gives the view model's writer coroutine a chance to drain what was queued. */ + private fun awaitWrite(expected: String) { + runBlocking { + repeat(TRIES) { + if (sessionFile.exists() && sessionFile.readText().contains(expected)) return@runBlocking + Thread.sleep(WAIT_MS) + } + } + } + + @Test + fun `givenNoFragment_whenOutputIsAppended_thenTheSessionFileReceivesIt`() { + viewModel.appendAsync("e: MainActivity.kt: (11, 23): Unresolved reference: Bundle") + + awaitWrite("Unresolved reference: Bundle") + + assertThat(sessionFile.readText()).contains("Unresolved reference: Bundle") + } + + @Test + fun `givenALineWithNoTrailingNewline_whenAppended_thenOneIsAdded`() { + viewModel.appendAsync("> Task :app:compileDebugKotlin") + + awaitWrite("compileDebugKotlin") + + assertThat(sessionFile.readText()).isEqualTo("> Task :app:compileDebugKotlin\n") + } + + @Test + fun `givenSeveralLines_whenAppended_thenTheyKeepTheirOrder`() { + viewModel.appendAsync("first\n") + viewModel.appendAsync("second\n") + viewModel.appendAsync("third\n") + + awaitWrite("third") + + assertThat(sessionFile.readText()).isEqualTo("first\nsecond\nthird\n") + } + + @Test + fun `givenOutputFromAFinishedBuild_whenTheSessionIsCleared_thenItDoesNotReachTheNewOne`() { + viewModel.appendAsync("e: an error from the previous build\n") + viewModel.clear() + + viewModel.appendAsync("> Task :app:compileDebugKotlin\n") + awaitWrite("compileDebugKotlin") + + assertThat(sessionFile.readText()).doesNotContain("previous build") + } + + @Test + fun `givenWrittenOutput_whenTheSessionIsCleared_thenTheFileIsGone`() { + viewModel.appendAsync("output\n") + awaitWrite("output") + + viewModel.clear() + + assertThat(sessionFile.exists()).isFalse() + } + + @Test + fun `givenEmptyText_whenAppended_thenNothingIsWritten`() { + viewModel.appendAsync("") + Thread.sleep(WAIT_MS * 2) + + assertThat(sessionFile.exists()).isFalse() + } + + private companion object { + /** Polling budget for the writer coroutine: generous, and only paid on a failure. */ + const val TRIES = 100 + const val WAIT_MS = 20L + } +} From efeada93e2a4e44ccdf881865659486a10003729 Mon Sep 17 00:00:00 2001 From: John Trujillo Date: Mon, 31 Aug 2026 15:06:15 -0500 Subject: [PATCH 2/2] fix(plugin-build): serialize builds, report dropped runs, unblock the UI ADFA-5307 review: claim InProgress atomically, report cancellation from the job so a destroyed scope still answers, and keep the log read off the UI. --- .../androidide/api/BuildOutputProvider.kt | 20 +++- .../androidide/app/PluginRunAppCoordinator.kt | 28 ++++-- .../fragments/output/BuildOutputFragment.kt | 8 +- .../androidide/viewmodel/BuildViewModel.kt | 19 ++-- .../api/BuildOutputProviderMainThreadTest.kt | 91 +++++++++++++++++++ .../app/PluginRunAppCoordinatorTest.kt | 66 ++++++++++++++ .../viewmodel/BuildViewModelTest.kt | 63 +++++++++++++ 7 files changed, 275 insertions(+), 20 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderMainThreadTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/app/PluginRunAppCoordinatorTest.kt create mode 100644 app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt b/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt index 02d65e2c53..681bf5f32d 100644 --- a/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt +++ b/app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt @@ -9,6 +9,7 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeoutOrNull +import org.slf4j.LoggerFactory import java.io.File import java.lang.ref.WeakReference @@ -17,6 +18,8 @@ import java.lang.ref.WeakReference * This acts as a service locator to avoid memory leaks. */ object BuildOutputProvider { + private val logger = LoggerFactory.getLogger(BuildOutputProvider::class.java) + // Both fields are written from the main thread (or a test) and read from the background thread // a plugin calls in on, so the reader needs the write to be visible. @Volatile @@ -46,7 +49,8 @@ object BuildOutputProvider { * the log matters most. * * Line timing prefixes are stripped; ~22 characters a line of clock time no agent can use. - * Does disk I/O, so call off the main thread. + * Does disk I/O, so call off the main thread; a main-thread caller gets the live content or + * nothing, never a blocking read. */ fun getBuildOutputContent(): String? { val content = liveContent() ?: sessionFileTail() ?: return null @@ -93,6 +97,14 @@ object BuildOutputProvider { */ private fun isMainThread(): Boolean = runCatching { Looper.myLooper() == Looper.getMainLooper() }.getOrDefault(true) + /** + * Whether the caller is on a real Android main thread. The counterpart to [isMainThread], and + * deliberately the opposite default: a JVM unit test has no main thread to block, so the file + * read there is safe, while [isMainThread] treats the same absence as "already where the live + * read has to happen". + */ + private fun isAndroidMainThread(): Boolean = runCatching { Looper.myLooper() == Looper.getMainLooper() }.getOrDefault(false) + /** Main thread only; see [liveContent]. */ private fun sheetContent(): String? = bottomSheetRef @@ -102,6 +114,12 @@ object BuildOutputProvider { ?.getShareableContent() private fun sessionFileTail(): String? { + if (isAndroidMainThread()) { + // Reading up to WINDOW_MAX_CHARS off disk is an ANR waiting to happen. The contract is + // an off-main-thread call; a caller that breaks it gets null rather than a stalled UI. + logger.warn("Skipping the build output session file: getBuildOutputContent() was called on the main thread") + return null + } val dir = sessionDirOverride ?: runCatching { IDEApplication.instance.cacheDir }.getOrNull() ?: return null // Read without BuildOutputViewModel's lock: a concurrent append can leave the window starting // mid-UTF-8-sequence, which decodes to a single U+FFFD rather than throwing. diff --git a/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt b/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt index 6079aed9d1..1073d28f4e 100644 --- a/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt +++ b/app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt @@ -57,17 +57,25 @@ internal object PluginRunAppCoordinator { } } - activity.lifecycleScope.launch { - try { - startBuild(activity, ::report) - } catch (e: CancellationException) { - // The editor was destroyed before the build was even handed off. Nothing will - // report later, so the caller has to hear it here or wait out its own timeout. + val job = + activity.lifecycleScope.launch { + try { + startBuild(activity, ::report) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + logger.error("Failed to run the app for a plugin", e) + report(false, "Error: ${e.message}") + } + } + + // An already-destroyed activity's scope drops the block without running it, so cancellation + // has to be reported from the job rather than from inside. Nothing else will report, and the + // caller would sit out its own timeout. A completed hand-off carries no cause, so the build + // still reports through runQuickBuild's callback. + job.invokeOnCompletion { cause -> + if (cause != null) { report(false, "The editor was closed before the build started.") - throw e - } catch (e: Exception) { - logger.error("Failed to run the app for a plugin", e) - report(false, "Error: ${e.message}") } } } 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 d0202acf78..ae07474ca4 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 @@ -430,7 +430,10 @@ class BuildOutputFragment : /** * Performs the safe UI update on the Main Thread. * - * Appends to the session file on a background dispatcher before switching to Main. + * Display only: the session file is written independently by + * [com.itsaky.androidide.ui.EditorBottomSheet.appendBuildOut], which sees every line whether or + * not this fragment exists. + * * Uses [IDEEditor.awaitLayout] to guarantee the editor has physical dimensions (width > 0) * before attempting to insert text, preventing the Sora library's `ArrayIndexOutOfBoundsException`. */ @@ -443,8 +446,7 @@ class BuildOutputFragment : // A clear (new build) after this batch was drained invalidates it. if (sessionGen != sessionGeneration) return - // Display only. The session file is written by EditorBottomSheet.appendBuildOut, which - // sees every line whether or not this fragment exists; the editor shows matching lines. + // The editor shows only the lines matching the current filter; the file keeps them all. val visibleText = BuildOutputViewModel.filterLines( text, diff --git a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt index 700b5ebf9d..8c90924c2a 100644 --- a/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt +++ b/app/src/main/java/com/itsaky/androidide/viewmodel/BuildViewModel.kt @@ -46,10 +46,19 @@ class BuildViewModel : ViewModel() { gradleArgs: List = emptyList(), onTerminalState: ((BuildState) -> Unit)? = null, ) { - if (_buildState.value is BuildState.InProgress) { - log.warn("Build is already in progress. Ignoring new request.") - onTerminalState?.invoke(BuildState.Error("A build is already in progress.")) - return + // Claim the slot before the coroutine is scheduled, and in one step: a check here and a set + // inside the launched block let two callers both read a free state and both reach + // executeTasks, running duplicate build-and-install flows. + while (true) { + val current = _buildState.value + if (current is BuildState.InProgress) { + log.warn("Build is already in progress. Ignoring new request.") + onTerminalState?.invoke(BuildState.Error("A build is already in progress.")) + return + } + if (_buildState.compareAndSet(current, BuildState.InProgress)) { + break + } } viewModelScope.launch { @@ -65,8 +74,6 @@ class BuildViewModel : ViewModel() { } } - _buildState.value = BuildState.InProgress - val buildService = Lookup.getDefault().lookup(BuildService.KEY_BUILD_SERVICE) if (buildService == null) { finish(BuildState.Error("Build service not found.")) diff --git a/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderMainThreadTest.kt b/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderMainThreadTest.kt new file mode 100644 index 0000000000..9765c64dea --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/api/BuildOutputProviderMainThreadTest.kt @@ -0,0 +1,91 @@ +/* + * 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.api + +import android.os.Looper +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.viewmodel.BuildOutputViewModel +import org.junit.After +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TemporaryFolder +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import java.io.File +import java.util.concurrent.Callable +import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit + +/** + * Covers the main-thread guard on [BuildOutputProvider]'s session-file fallback. Robolectric is + * what makes this testable: the sibling [BuildOutputProviderTest] runs with no [android.os.Looper] + * at all, which is indistinguishable from "not the main thread". + */ +@RunWith(RobolectricTestRunner::class) +class BuildOutputProviderMainThreadTest { + @get:Rule + val tempFolder = TemporaryFolder() + + @After + fun tearDown() { + BuildOutputProvider.setSessionDirectoryForTest(null) + BuildOutputProvider.clearBottomSheet() + } + + @Test + fun `givenASessionFile_whenReadOnTheMainThread_thenNothingIsReadFromDisk`() { + writeSessionFile("> Task :app:compileV8DebugKotlin\n") + + // Robolectric runs the test body on the main thread, so this is the ANR case. + assertThat(BuildOutputProvider.getBuildOutputContent()).isNull() + } + + @Test + fun `givenASessionFile_whenReadOffTheMainThread_thenItIsReturned`() { + writeSessionFile("e: Foo.kt:12:5 unresolved reference: bar\n") + + assertThat(offMainThread { BuildOutputProvider.getBuildOutputContent() }) + .isEqualTo("e: Foo.kt:12:5 unresolved reference: bar\n") + } + + /** + * Runs [block] on a background thread while pumping the main looper. The live read dispatches + * to the main thread, and under Robolectric that queue only drains when this thread drains it -- + * blocking on the result instead would deadlock the two against each other. + */ + private fun offMainThread(block: () -> T): T { + val executor = Executors.newSingleThreadExecutor() + try { + val result = executor.submit(Callable { block() }) + val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10) + while (!result.isDone && System.nanoTime() < deadline) { + shadowOf(Looper.getMainLooper()).idle() + Thread.sleep(5) + } + return result.get(1, TimeUnit.SECONDS) + } finally { + executor.shutdownNow() + } + } + + private fun writeSessionFile(content: String) { + File(tempFolder.root, BuildOutputViewModel.SESSION_FILE_NAME).writeText(content) + BuildOutputProvider.setSessionDirectoryForTest(tempFolder.root) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/app/PluginRunAppCoordinatorTest.kt b/app/src/test/java/com/itsaky/androidide/app/PluginRunAppCoordinatorTest.kt new file mode 100644 index 0000000000..12f9a89124 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/app/PluginRunAppCoordinatorTest.kt @@ -0,0 +1,66 @@ +/* + * 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.app + +import android.os.Looper +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.activities.editor.ProjectHandlerActivity +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf + +/** + * Covers the paths on which [PluginRunAppCoordinator] never reaches a build: a plugin waits on + * [com.itsaky.androidide.plugins.services.BuildAndLaunchCallback], so every one of them has to + * answer rather than leave the caller on its own timeout. + */ +@RunWith(RobolectricTestRunner::class) +class PluginRunAppCoordinatorTest { + @Test + fun `givenNoEditorActivity_whenRunningTheApp_thenTheCallerIsToldImmediately`() { + val outcomes = mutableListOf>() + + PluginRunAppCoordinator.runApp(null) { success, message -> outcomes += success to message } + + assertThat(outcomes).hasSize(1) + assertThat(outcomes.single().first).isFalse() + } + + @Test + fun `givenADestroyedEditor_whenRunningTheApp_thenCancellationIsReportedOnce`() { + val activity = mockk() + val lifecycle = LifecycleRegistry.createUnsafe(activity) + every { activity.lifecycle } returns lifecycle + // A destroyed scope drops the launched block entirely, so nothing inside it can report. + // DESTROYED is only reachable from CREATED; there is no event down from INITIALIZED. + lifecycle.currentState = Lifecycle.State.CREATED + lifecycle.currentState = Lifecycle.State.DESTROYED + + val outcomes = mutableListOf>() + PluginRunAppCoordinator.runApp(activity) { success, message -> outcomes += success to message } + shadowOf(Looper.getMainLooper()).idle() + + assertThat(outcomes).hasSize(1) + assertThat(outcomes.single().first).isFalse() + } +} diff --git a/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt new file mode 100644 index 0000000000..0d6f0a1f7a --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/viewmodel/BuildViewModelTest.kt @@ -0,0 +1,63 @@ +/* + * 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.viewmodel + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.project.AndroidModels +import com.itsaky.androidide.projects.api.AndroidModule +import io.mockk.mockk +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher +import org.junit.Rule +import org.junit.Test + +/** + * Covers [BuildViewModel.runQuickBuild]'s single-build guard. The dispatcher is deliberately + * [StandardTestDispatcher] rather than the unconfined default: nothing the view model launches runs + * until the test advances it, which is the window a second caller races through. + */ +@OptIn(ExperimentalCoroutinesApi::class) +class BuildViewModelTest { + @get:Rule + val mainDispatcherRule = MainDispatcherRule(StandardTestDispatcher()) + + private val module = mockk(relaxed = true) + private val variant: AndroidModels.AndroidVariant = AndroidModels.AndroidVariant.getDefaultInstance() + + @Test + fun `givenAQueuedBuild_whenASecondRequestArrivesBeforeItRuns_thenTheSecondIsRejected`() { + val viewModel = BuildViewModel() + val outcomes = mutableListOf() + + // Neither launched block has run, so the second call sees exactly what a second thread + // would racing the first: the state a coroutine body has not had the chance to claim yet. + viewModel.runQuickBuild(module, variant, launchInDebugMode = false) { outcomes += it } + viewModel.runQuickBuild(module, variant, launchInDebugMode = false) { outcomes += it } + + assertThat(outcomes).containsExactly(BuildState.Error("A build is already in progress.")) + } + + @Test + fun `givenNoBuild_whenRequestingOne_thenTheStateIsClaimedBeforeTheCoroutineRuns`() { + val viewModel = BuildViewModel() + + viewModel.runQuickBuild(module, variant, launchInDebugMode = false) + + assertThat(viewModel.buildState.value).isEqualTo(BuildState.InProgress) + } +}