Skip to content
Merged
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
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) }
Comment thread
jatezzz marked this conversation as resolved.
// 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