diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index a19cf61..039e6d5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -61,6 +61,10 @@ + + () + private val timeFormatter = DateFormat.getTimeInstance(DateFormat.SHORT) + private var showAll = false + private var entries: List = emptyList() + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + binding = ActivityExecutionLogBinding.inflate(layoutInflater) + setContentView(binding.root) + ViewCompat.setOnApplyWindowInsetsListener(binding.root) { view, insets -> + val systemBars = insets.getInsets(WindowInsetsCompat.Type.systemBars()) + view.setPadding(0, systemBars.top, 0, systemBars.bottom) + insets + } + + binding.backButton.setOnClickListener { finish() } + binding.showMoreButton.setOnClickListener { + showAll = true + render() + } + + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.STARTED) { + ExecutionLogStore.entries.collect { latest -> + entries = latest + render() + } + } + } + } + + private fun render() { + binding.emptyText.visibility = if (entries.isEmpty()) View.VISIBLE else View.GONE + val shownEntries = if (showAll) entries else entries.take(DEFAULT_VISIBLE_ENTRIES) + binding.logList.removeAllViews() + shownEntries.forEach { entry -> addEntry(entry) } + binding.showMoreButton.visibility = + if (!showAll && entries.size > DEFAULT_VISIBLE_ENTRIES) View.VISIBLE else View.GONE + } + + private fun addEntry(entry: ExecutionLogEntry) { + val row = ItemExecutionLogBinding.inflate( + LayoutInflater.from(this), + binding.logList, + false, + ) + val expanded = entry.id in expandedIds + val meta = entry.metaLine() + row.headlineText.text = entry.headline + row.metaText.text = meta + row.statusIndicator.backgroundTintList = ColorStateList.valueOf( + ContextCompat.getColor( + this, + when { + entry.isRunning -> R.color.signal_amber + entry.exitCode == 0 -> R.color.circuit_green + else -> R.color.infra_red + }, + ), + ) + row.expandIndicator.text = if (expanded) "⌃" else "⌄" + row.detailPanel.visibility = if (expanded) View.VISIBLE else View.GONE + row.commandText.text = entry.script.ifBlank { getString(R.string.logs_empty_command) } + row.resultText.text = entry.resultText() + row.summaryRow.contentDescription = getString( + if (expanded) R.string.logs_collapse_entry else R.string.logs_expand_entry, + "${entry.headline}, $meta", + ) + row.summaryRow.setOnClickListener { + if (!expandedIds.add(entry.id)) expandedIds.remove(entry.id) + render() + } + binding.logList.addView(row.root) + } + + private fun ExecutionLogEntry.metaLine(): String { + val time = timeFormatter.format(Date(startedAtMillis)) + val status = when { + isRunning -> getString(R.string.logs_running) + exitCode == 0 -> getString(R.string.logs_done) + else -> getString(R.string.logs_failed, exitCode ?: 1) + } + val duration = durationMillis?.let { " · ${formatDuration(it)}" }.orEmpty() + return "$time · $status$duration" + } + + private fun ExecutionLogEntry.resultText(): String { + if (isRunning) return getString(R.string.logs_running_detail) + return buildString { + if (stdout.isNotEmpty()) append(stdout.trimEnd()) + if (stderr.isNotEmpty()) { + if (isNotEmpty()) append("\n\n") + append(getString(R.string.logs_stderr)).append('\n').append(stderr.trimEnd()) + } + if (isEmpty()) append(getString(R.string.logs_no_output)) + } + } + + private fun formatDuration(millis: Long): String = when { + millis < 1_000 -> "${millis}ms" + millis < 10_000 -> String.format(Locale.getDefault(), "%.1fs", millis / 1_000.0) + else -> "${millis / 1_000}s" + } + + companion object { + private const val DEFAULT_VISIBLE_ENTRIES = 5 + } +} diff --git a/app/src/main/java/ai/sealgate/stdiod/ExecutionLogStore.kt b/app/src/main/java/ai/sealgate/stdiod/ExecutionLogStore.kt new file mode 100644 index 0000000..edede6f --- /dev/null +++ b/app/src/main/java/ai/sealgate/stdiod/ExecutionLogStore.kt @@ -0,0 +1,106 @@ +package ai.sealgate.stdiod + +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow +import java.util.concurrent.atomic.AtomicLong + +/** A bounded, in-memory history of the Bash requests agents run on this phone. */ +data class ExecutionLogEntry( + val id: Long, + val script: String, + val startedAtMillis: Long, + val finishedAtMillis: Long? = null, + val stdout: String = "", + val stderr: String = "", + val exitCode: Int? = null, +) { + val headline: String + get() { + val firstCommand = script.lineSequence() + .map(String::trim) + .firstOrNull(String::isNotEmpty) + .orEmpty() + .replace(Regex("\\s+"), " ") + return when { + firstCommand.isEmpty() -> "Empty command" + firstCommand.length <= HEADLINE_CHARACTERS -> firstCommand + else -> firstCommand.take(HEADLINE_CHARACTERS - 1).trimEnd() + "…" + } + } + + val isRunning: Boolean get() = finishedAtMillis == null + val durationMillis: Long? get() = finishedAtMillis?.minus(startedAtMillis)?.coerceAtLeast(0) + + companion object { + private const val HEADLINE_CHARACTERS = 72 + } +} + +object ExecutionLogStore { + private const val MAX_ENTRIES = 50 + private const val MAX_OUTPUT_CHARACTERS = 16 * 1024 + private const val OUTPUT_TRUNCATED_NOTICE = "\n… output clipped in log" + + private val lock = Any() + private val nextId = AtomicLong() + private val _entries = MutableStateFlow>(emptyList()) + val entries: StateFlow> = _entries.asStateFlow() + + fun begin(script: String, startedAtMillis: Long = System.currentTimeMillis()): Long { + val id = nextId.incrementAndGet() + synchronized(lock) { + _entries.value = listOf( + ExecutionLogEntry( + id = id, + script = script, + startedAtMillis = startedAtMillis, + ), + ) + _entries.value.take(MAX_ENTRIES - 1) + } + return id + } + + fun finish( + id: Long, + stdout: String, + stderr: String, + exitCode: Int, + finishedAtMillis: Long = System.currentTimeMillis(), + ) = update(id) { entry -> + entry.copy( + finishedAtMillis = finishedAtMillis, + stdout = stdout.clipped(), + stderr = stderr.clipped(), + exitCode = exitCode, + ) + } + + fun fail( + id: Long, + error: Throwable, + finishedAtMillis: Long = System.currentTimeMillis(), + ) = update(id) { entry -> + entry.copy( + finishedAtMillis = finishedAtMillis, + stderr = (error.message ?: error.javaClass.simpleName).clipped(), + exitCode = 1, + ) + } + + private fun update(id: Long, transform: (ExecutionLogEntry) -> ExecutionLogEntry) { + synchronized(lock) { + _entries.value = _entries.value.map { entry -> + if (entry.id == id) transform(entry) else entry + } + } + } + + private fun String.clipped(): String = + if (length <= MAX_OUTPUT_CHARACTERS) this + else take(MAX_OUTPUT_CHARACTERS) + OUTPUT_TRUNCATED_NOTICE + + internal fun clear() = synchronized(lock) { + _entries.value = emptyList() + } +} diff --git a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt index 41576b5..2a7032e 100644 --- a/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt +++ b/app/src/main/java/ai/sealgate/stdiod/MainActivity.kt @@ -116,6 +116,10 @@ class MainActivity : AppCompatActivity() { ) } + binding.logsButton.setOnClickListener { + startActivity(Intent(this, ExecutionLogActivity::class.java)) + } + binding.connectionInfoButton.setOnClickListener { MaterialAlertDialogBuilder(this) .setTitle(R.string.connection_info_title) diff --git a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt index eba9d51..a796a13 100644 --- a/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt +++ b/app/src/main/java/ai/sealgate/stdiod/TunnelService.kt @@ -241,7 +241,7 @@ class TunnelService : LifecycleService() { // notifications and stays far denser than a decorated RemoteViews panel. .setContentTitle(status) .setContentText(route) - .setSmallIcon(R.drawable.ic_stat_sealgate) + .setSmallIcon(R.drawable.ic_notification_sealgate_safe) .setLargeIcon(sealGateLogo) .setStyle(pictureStyle) .setColor(statusColor) diff --git a/app/src/main/java/ai/sealgate/stdiod/mcp/BashModule.kt b/app/src/main/java/ai/sealgate/stdiod/mcp/BashModule.kt index 7dc87e9..faca9c5 100644 --- a/app/src/main/java/ai/sealgate/stdiod/mcp/BashModule.kt +++ b/app/src/main/java/ai/sealgate/stdiod/mcp/BashModule.kt @@ -1,5 +1,6 @@ package ai.sealgate.stdiod.mcp +import ai.sealgate.stdiod.ExecutionLogStore import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.JsonObject @@ -77,7 +78,19 @@ class BashModule( return JsonRpc.error(JsonNull, INVALID_REQUEST, "request id is too large") } - val result = runtime.execute(script) + val logId = ExecutionLogStore.begin(script) + val result = try { + runtime.execute(script) + } catch (error: Exception) { + ExecutionLogStore.fail(logId, error) + throw error + } + ExecutionLogStore.finish( + id = logId, + stdout = result.stdout, + stderr = result.stderr, + exitCode = result.exitCode, + ) val text = buildString { if (result.stdout.isNotEmpty()) append(result.stdout) if (result.stderr.isNotEmpty()) { diff --git a/app/src/main/res/drawable/ic_chevron.xml b/app/src/main/res/drawable/ic_chevron.xml new file mode 100644 index 0000000..1a0a091 --- /dev/null +++ b/app/src/main/res/drawable/ic_chevron.xml @@ -0,0 +1,14 @@ + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index a2758da..7e88c7b 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,17 +1,7 @@ - - - - - - + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 0000000..0b82a1d --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_stat_sealgate.xml b/app/src/main/res/drawable/ic_notification_sealgate_safe.xml similarity index 98% rename from app/src/main/res/drawable/ic_stat_sealgate.xml rename to app/src/main/res/drawable/ic_notification_sealgate_safe.xml index 308b4f2..87e704f 100644 --- a/app/src/main/res/drawable/ic_stat_sealgate.xml +++ b/app/src/main/res/drawable/ic_notification_sealgate_safe.xml @@ -1,5 +1,5 @@ - + + diff --git a/app/src/main/res/layout/activity_execution_log.xml b/app/src/main/res/layout/activity_execution_log.xml new file mode 100644 index 0000000..6c831b5 --- /dev/null +++ b/app/src/main/res/layout/activity_execution_log.xml @@ -0,0 +1,89 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1e38eb3..f450589 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -41,6 +41,17 @@ android:textSize="30sp" android:textStyle="bold" /> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 6b78462..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,4 +2,5 @@ + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 6b78462..8fde456 100644 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,4 +2,5 @@ + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 9504d27..47dd27a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -11,6 +11,23 @@ Your phone creates an encrypted connection to the SealGate gateway. Nothing on the internet can connect directly to your phone.\n\nTechnical details\nThe app uses one outbound secure WebSocket (WSS) and does not open an inbound port. Version %1$s · build %2$d Close + Logs + Back + + Logs + No executions yet + Show more + Running + Done + Failed · code %1$d + Execution in progress… + No output + No command + stderr + Command + Result + Expand %1$s + Collapse %1$s Ready to connect Local tools diff --git a/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt b/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt new file mode 100644 index 0000000..e495f62 --- /dev/null +++ b/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt @@ -0,0 +1,81 @@ +package ai.sealgate.stdiod + +import ai.sealgate.stdiod.mcp.BashExecutionResult +import ai.sealgate.stdiod.mcp.BashModule +import ai.sealgate.stdiod.mcp.MobileBashRuntime +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.jsonObject +import org.junit.After +import org.junit.Before +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExecutionLogStoreTest { + @Before + fun setUp() = ExecutionLogStore.clear() + + @After + fun tearDown() = ExecutionLogStore.clear() + + @Test + fun recordsRunningThenCompletedExecution() { + val id = ExecutionLogStore.begin("battery status\n", startedAtMillis = 100) + + val running = ExecutionLogStore.entries.value.single() + assertEquals("battery status", running.headline) + assertTrue(running.isRunning) + + ExecutionLogStore.finish(id, "84\n", "", 0, finishedAtMillis = 145) + + val completed = ExecutionLogStore.entries.value.single() + assertFalse(completed.isRunning) + assertEquals(45L, completed.durationMillis) + assertEquals(0, completed.exitCode) + assertEquals("84\n", completed.stdout) + } + + @Test + fun keepsOnlyTheMostRecentFiftyEntries() { + repeat(55) { index -> ExecutionLogStore.begin("command $index") } + + val entries = ExecutionLogStore.entries.value + assertEquals(50, entries.size) + assertEquals("command 54", entries.first().headline) + assertEquals("command 5", entries.last().headline) + } + + @Test + fun clipsLongHeadlinesAndOutput() { + val id = ExecutionLogStore.begin("x".repeat(100)) + ExecutionLogStore.finish(id, "o".repeat(20_000), "", 0) + + val entry = ExecutionLogStore.entries.value.single() + assertTrue(entry.headline.endsWith("…")) + assertTrue(entry.stdout.endsWith("… output clipped in log")) + assertTrue(entry.stdout.length < 20_000) + } + + @Test + fun bashToolCallsAreAddedToTheLog() { + val module = BashModule( + runtimeFactory = { + object : MobileBashRuntime { + override fun execute(script: String) = BashExecutionResult("Pixel 6\n", "", 0) + override fun close() = Unit + } + }, + ) + val request = Json.parseToJsonElement( + """{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"run","arguments":{"script":"device info"}}}""", + ).jsonObject + + module.handle(request) + + val entry = ExecutionLogStore.entries.value.single() + assertEquals("device info", entry.headline) + assertEquals("Pixel 6\n", entry.stdout) + assertEquals(0, entry.exitCode) + } +}