Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
118 changes: 116 additions & 2 deletions app/src/main/java/com/itsaky/androidide/api/BuildOutputProvider.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,34 @@
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 org.slf4j.LoggerFactory
import java.io.File
import java.lang.ref.WeakReference

/**
* Provides access to the EditorBottomSheet instance from a decoupled context.
* 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
private var bottomSheetRef: WeakReference<EditorBottomSheet>? = 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)
}
Expand All @@ -19,8 +38,103 @@ 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; a main-thread caller gets the live content or
* nothing, never a blocking read.
*/
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)
Comment thread
coderabbitai[bot] marked this conversation as resolved.

/**
* 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
?.get()
?.pagerAdapter
?.buildOutputFragment
?.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.
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
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
}
}
}
Expand Down
143 changes: 143 additions & 0 deletions app/src/main/java/com/itsaky/androidide/app/PluginRunAppCoordinator.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
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)
}
}

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.")
}
}
}

/**
* 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<Boolean, String> =
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."
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
*/
Expand All @@ -440,12 +443,10 @@ 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
// The editor shows only the lines matching the current filter; the file keeps them all.
val visibleText =
BuildOutputViewModel.filterLines(
text,
Expand Down
Loading
Loading