Skip to content
Open
138 changes: 108 additions & 30 deletions app/src/main/java/com/itsaky/androidide/app/PluginTerminalLauncher.kt
Original file line number Diff line number Diff line change
Expand Up @@ -4,72 +4,139 @@ import android.app.Activity
import android.content.Intent
import android.os.Handler
import android.os.Looper
import androidx.core.os.HandlerCompat
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import com.itsaky.androidide.activities.TerminalActivity
import com.itsaky.androidide.plugins.manager.services.LaunchedTerminalCommand
import com.itsaky.androidide.plugins.manager.services.TerminalSessionLauncher
import com.itsaky.androidide.plugins.services.TerminalCommandResult
import com.itsaky.androidide.terminal.AgentRunner
import com.itsaky.androidide.terminal.CommandIntentRouter
import com.itsaky.androidide.terminal.CommandState
import com.itsaky.androidide.terminal.TerminalCommandListener
import com.itsaky.androidide.terminal.TerminalCommandRequests
import com.itsaky.androidide.terminal.TerminalStartFailure
import com.itsaky.androidide.utils.applyMultiWindowFlags
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import org.slf4j.LoggerFactory
import java.io.File

/**
* Opens a plugin's command in a new Terminal session, through the same activity the Terminal
* sidebar action opens.
* Runs a plugin's command in one of its Terminal sessions, through the same activity the Terminal
* sidebar action opens. [TerminalCommandRequests] is only touched on the main thread.
*/
internal class PluginTerminalLauncher(
private val runner: AgentRunner = AgentRunner.termux,
private val requests: TerminalCommandRequests = TerminalCommandRequests.shared,
private val router: CommandIntentRouter = CommandIntentRouter.shared,
private val foregroundActivity: () -> Activity?,
) : TerminalSessionLauncher {
private val mainHandler = Handler(Looper.getMainLooper())

override fun launch(
override suspend fun launch(
command: String,
workingDirectory: File?,
sessionName: String,
onResult: (TerminalCommandResult) -> Unit,
): () -> Unit {
pluginId: String,
sessionLabel: String,
): LaunchedTerminalCommand {
val workDir = workingDirectory?.absolutePath
val id = withContext(Dispatchers.IO) { runner.prepare(command, workDir) }
// Nothing suspends from here on, so a command handed to the Terminal is always returned.
val launched = Launched(id)
mainHandler.post { open(id, workDir, pluginId, sessionLabel, launched) }
return launched
}

override suspend fun read(
pluginId: String,
commandId: String,
): TerminalCommandResult? = onMain { requests.read(pluginId, commandId)?.toResult() }

override suspend fun interrupt(
pluginId: String,
commandId: String,
): Deferred<TerminalCommandResult>? = onMain { (requests.interrupt(pluginId, commandId) as? Launched)?.result }

private suspend fun <T> onMain(block: () -> T): T = withContext(Dispatchers.Main) { block() }

private fun open(
id: String,
workingDirectory: String?,
pluginId: String,
sessionLabel: String,
listener: TerminalCommandListener,
) {
requests.enqueue(id, workingDirectory, pluginId, listener, sessionLabel)

// Android blocks activity starts from the background, so a plugin can only open the
// Terminal while the IDE is on screen. foregroundActivity() still returns a backgrounded
// activity (it is cleared only on finish or destroy), and a blocked start throws nothing,
// so check the activity is at least STARTED or the plugin waits out OPEN_TIMEOUT_MS.
val activity = foregroundActivity()?.takeIf { it.isStarted() }
if (activity == null) {
onResult(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
return {}
requests.withdraw(id, TerminalStartFailure.NotInForeground)
return
}

val requestId =
TerminalCommandRequests.enqueue(
command = command,
workingDirectory = workingDirectory?.absolutePath,
sessionName = sessionName,
onExit = { exitCode, transcript -> onResult(TerminalCommandResult.Completed(exitCode, transcript)) },
onNotStarted = { reason -> onResult(TerminalCommandResult.NotStarted(reason)) },
)
val intent =
Intent(activity, TerminalActivity::class.java)
.putExtra(TerminalCommandRequests.EXTRA_COMMAND_REQUEST_ID, requestId)
.applyMultiWindowFlags(activity)
val intent = router.putRequestId(Intent(activity, TerminalActivity::class.java), id).applyMultiWindowFlags(activity)
try {
activity.startActivity(intent)
} catch (e: Exception) {
logger.error("Failed to open the Terminal for a plugin command", e)
if (TerminalCommandRequests.withdraw(requestId)) {
onResult(TerminalCommandResult.NotStarted("The Terminal could not be opened: ${e.message}"))
}
return {}
requests.withdraw(id, TerminalStartFailure.TerminalNotOpened)
return
}

// The activity can fail to start or finish before its service connects; without this the
// plugin would wait forever for a session that never comes.
mainHandler.postDelayed({
if (TerminalCommandRequests.withdraw(requestId)) {
onResult(TerminalCommandResult.NotStarted("The Terminal did not open"))
}
}, OPEN_TIMEOUT_MS)
// Posted with the listener as its token, which removes it once the Terminal reports back.
HandlerCompat.postDelayed(
mainHandler,
{ requests.withdraw(id, TerminalStartFailure.TerminalDidNotOpen) },
listener,
OPEN_TIMEOUT_MS,
)
}

/** Command [id] as the plugin service sees it, fed by the Terminal's reports on the main thread. */
private inner class Launched(
private val id: String,
) : LaunchedTerminalCommand,
TerminalCommandListener {
override val started = CompletableDeferred<Unit>()
override val result = CompletableDeferred<TerminalCommandResult>()

// Posted after the open in launch, so the command is queued by the time it is cancelled.
override fun interrupt() {
mainHandler.post { requests.cancel(id) }
// A command that ignores Ctrl-C would hold its session until the user ends it.
mainHandler.postDelayed({ requests.kill(id) }, KILL_GRACE_MS)
}

return { TerminalCommandRequests.cancel(requestId) }
override suspend fun snapshot(): TerminalCommandResult.Running? = onMain { requests.snapshot(id)?.toResult() }

override fun onStarted(sessionName: String) {
cancelOpenTimeout()
started.complete(Unit)
}

override fun onExited(
exitCode: Int,
output: String,
) {
result.complete(TerminalCommandResult.Completed(exitCode, output))
}

override fun onNotStarted(reason: TerminalStartFailure) {
cancelOpenTimeout()
result.complete(TerminalCommandResult.NotStarted(reason.message))
}

private fun cancelOpenTimeout() = mainHandler.removeCallbacksAndMessages(this)
}

private fun Activity.isStarted(): Boolean =
Expand All @@ -82,5 +149,16 @@ internal class PluginTerminalLauncher(
private val logger = LoggerFactory.getLogger(PluginTerminalLauncher::class.java)

const val OPEN_TIMEOUT_MS = 15_000L

// How long an interrupted command has to exit before its session is ended.
const val KILL_GRACE_MS = 5_000L

fun CommandState.Running.toResult() = TerminalCommandResult.Running(commandId, sessionName, output)

fun CommandState.toResult(): TerminalCommandResult =
when (this) {
is CommandState.Running -> toResult()
is CommandState.Exited -> TerminalCommandResult.Completed(exitCode, output)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
package com.itsaky.androidide.app

import android.app.Activity
import android.app.Application
import android.os.Looper
import androidx.activity.ComponentActivity
import com.google.common.truth.Truth.assertThat
import com.itsaky.androidide.plugins.manager.services.LaunchedTerminalCommand
import com.itsaky.androidide.plugins.services.TerminalCommandResult
import com.itsaky.androidide.terminal.AgentRunner
import com.itsaky.androidide.terminal.TerminalStartFailure
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.runBlocking
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TemporaryFolder
import org.junit.runner.RunWith
import org.robolectric.Robolectric
import org.robolectric.RobolectricTestRunner
Expand All @@ -16,9 +25,23 @@ import org.robolectric.annotation.Config
* mean the IDE is on screen. A start from the background is dropped without an exception, so
* the launcher has to refuse up front or the plugin waits out the open timeout.
*/
@OptIn(ExperimentalCoroutinesApi::class)
@RunWith(RobolectricTestRunner::class)
@Config(application = Application::class)
class PluginTerminalLauncherTest {
@get:Rule
val tmp = TemporaryFolder()

// The open is posted to the main looper, which Robolectric runs only when idled.
private fun launch(activity: Activity): LaunchedTerminalCommand {
val launched =
runBlocking {
PluginTerminalLauncher(runner = AgentRunner(tmp.root)) { activity }.launch("true", null, "plugin", "plugin")
}
shadowOf(Looper.getMainLooper()).idle()
return launched
}

@Test
fun `a backgrounded IDE is refused at once and nothing is started`() {
val controller =
Expand All @@ -28,23 +51,24 @@ class PluginTerminalLauncherTest {
.pause()
.stop()
val activity = controller.get()
val results = mutableListOf<TerminalCommandResult>()

PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it }
val launched = launch(activity)

assertThat(results).containsExactly(TerminalCommandResult.NotStarted("Code On the Go is not in the foreground"))
assertThat(launched.result.isCompleted).isTrue()
assertThat(launched.result.getCompleted())
.isEqualTo(TerminalCommandResult.NotStarted(TerminalStartFailure.NotInForeground.message))
assertThat(shadowOf(activity).nextStartedActivity).isNull()
}

@Test
fun `an IDE on screen opens the Terminal`() {
val activity = Robolectric.buildActivity(ComponentActivity::class.java).setup().get()
val results = mutableListOf<TerminalCommandResult>()

val cancel = PluginTerminalLauncher { activity }.launch("true", null, "plugin") { results += it }
val launched = launch(activity)

assertThat(results).isEmpty()
assertThat(launched.result.isCompleted).isFalse()
assertThat(shadowOf(activity).nextStartedActivity).isNotNull()
cancel()
launched.interrupt()
shadowOf(Looper.getMainLooper()).idle()
}
}
22 changes: 16 additions & 6 deletions docs/PLUGIN_API_CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,13 +36,23 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed]
= diffed from `plugin-api/src` history (predates the dump; symbol-accurate).

### 26.41 — unreleased
- **added — Terminal readiness and visible terminal commands** _(ADFA-6373)_ **[verified]**
- **added — Terminal readiness and visible terminal commands** _(ADFA-6373, ADFA-6385)_ **[verified]**
`IdeTerminalService.isTerminalReady()` reports whether the terminal environment is installed
and bash runs; it needs no permission. `runInTerminal(command, workingDirectory)` opens a new
session in the visible Terminal, runs the command with bash and suspends until it exits,
returning `TerminalCommandResult.Completed(exitCode, output)` with the session transcript, or
`NotStarted(reason)` (environment missing, IDE not in the foreground). The session stays open
so the user sees what ran; cancelling the caller kills the command. Needs `system.commands`;
and bash runs; it needs no permission. `runInTerminal(command, workingDirectory, waitMillis)`
runs the command with bash in the visible Terminal, in an idle session of the plugin; a new
session opens only while the plugin's others are busy, up to 3. Each command runs in its own
bash process, so `cd` and `export` do not carry over; it starts from the session's login shell,
so it sees the user's profile and anything the user exported there. It returns
`TerminalCommandResult.Completed(exitCode, output)` with that command's output, `Running(commandId,
sessionName, output)` when the command is still running after `waitMillis` (default 30 s; it keeps running), or
`NotStarted(reason)` (environment missing, IDE not in the foreground, all 3 sessions busy, naming them).
Cancelling the caller, or unloading the plugin, interrupts the command with Ctrl-C and ends its
session if it has not exited a few seconds later. A session where the user started a program of
their own is busy. `readCommand(commandId)` returns that command, `Running` or `Completed`, even
after another command reuses its session; null for an id that is not the plugin's, or a command
that exited before the plugin's last 8 others did. `stopCommand(commandId, waitMillis)` interrupts
that command with Ctrl-C and returns its state as `readCommand` does after waiting up to
`waitMillis` for it to exit; it never touches a later command in the same session. All three need `system.commands`;
the working directory must lie inside the project. Floor `plugin.min_ide_version` at `26.41`:
an older IDE has no `IdeTerminalService` class, so referencing it fails to load.
- **added — Run Gradle tasks with arguments, get a structured result, cancel** _(ADFA-6373)_ **[verified]**
Expand Down
30 changes: 28 additions & 2 deletions plugin-api/api/plugin-api.api
Original file line number Diff line number Diff line change
Expand Up @@ -1849,12 +1849,23 @@ public abstract interface class com/itsaky/androidide/plugins/services/IdeTempla
}

public abstract interface class com/itsaky/androidide/plugins/services/IdeTerminalService {
public static final field Companion Lcom/itsaky/androidide/plugins/services/IdeTerminalService$Companion;
public static final field DEFAULT_STOP_WAIT_MILLIS J
public static final field DEFAULT_WAIT_MILLIS J
public abstract fun isTerminalReady (Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun readCommand (Ljava/lang/String;Lkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun runInTerminal (Ljava/lang/String;Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
public abstract fun stopCommand (Ljava/lang/String;JLkotlin/coroutines/Continuation;)Ljava/lang/Object;
}

public final class com/itsaky/androidide/plugins/services/IdeTerminalService$Companion {
public static final field DEFAULT_STOP_WAIT_MILLIS J
public static final field DEFAULT_WAIT_MILLIS J
}

public final class com/itsaky/androidide/plugins/services/IdeTerminalService$DefaultImpls {
public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;Lkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun runInTerminal$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;Ljava/lang/String;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun stopCommand$default (Lcom/itsaky/androidide/plugins/services/IdeTerminalService;Ljava/lang/String;JLkotlin/coroutines/Continuation;ILjava/lang/Object;)Ljava/lang/Object;
}

public abstract interface class com/itsaky/androidide/plugins/services/IdeThemeService {
Expand Down Expand Up @@ -2195,6 +2206,21 @@ public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$
public fun toString ()Ljava/lang/String;
}

public final class com/itsaky/androidide/plugins/services/TerminalCommandResult$Running : com/itsaky/androidide/plugins/services/TerminalCommandResult {
public fun <init> (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)V
public final fun component1 ()Ljava/lang/String;
public final fun component2 ()Ljava/lang/String;
public final fun component3 ()Ljava/lang/String;
public final fun copy (Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running;
public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running;Ljava/lang/String;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/services/TerminalCommandResult$Running;
public fun equals (Ljava/lang/Object;)Z
public final fun getCommandId ()Ljava/lang/String;
public final fun getOutput ()Ljava/lang/String;
public final fun getSessionName ()Ljava/lang/String;
public fun hashCode ()I
public fun toString ()Ljava/lang/String;
}

public abstract interface class com/itsaky/androidide/plugins/services/ThemeChangeListener {
public abstract fun onThemeChanged (Z)V
}
Expand Down
Loading
Loading