From 1749b6a4018a7b644e36763c4c661a9235819ea3 Mon Sep 17 00:00:00 2001 From: Eito Miyamura Date: Wed, 2 Sep 2026 16:56:31 +0100 Subject: [PATCH 1/2] Add execution logs and SealGate notification icon --- app/src/main/AndroidManifest.xml | 4 + .../sealgate/stdiod/ExecutionLogActivity.kt | 130 ++++++++++++++++++ .../ai/sealgate/stdiod/ExecutionLogStore.kt | 106 ++++++++++++++ .../java/ai/sealgate/stdiod/MainActivity.kt | 4 + .../java/ai/sealgate/stdiod/TunnelService.kt | 2 +- .../java/ai/sealgate/stdiod/mcp/BashModule.kt | 15 +- app/src/main/res/drawable/ic_chevron.xml | 14 ++ .../res/drawable/ic_launcher_foreground.xml | 24 +--- .../res/drawable/ic_launcher_monochrome.xml | 7 + .../ic_notification_sealgate_safe.xml | 22 +++ .../res/layout/activity_execution_log.xml | 88 ++++++++++++ app/src/main/res/layout/activity_main.xml | 11 ++ .../main/res/layout/item_execution_log.xml | 127 +++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 1 + .../mipmap-anydpi-v26/ic_launcher_round.xml | 1 + app/src/main/res/values/strings.xml | 17 +++ .../sealgate/stdiod/ExecutionLogStoreTest.kt | 77 +++++++++++ 17 files changed, 631 insertions(+), 19 deletions(-) create mode 100644 app/src/main/java/ai/sealgate/stdiod/ExecutionLogActivity.kt create mode 100644 app/src/main/java/ai/sealgate/stdiod/ExecutionLogStore.kt create mode 100644 app/src/main/res/drawable/ic_chevron.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml create mode 100644 app/src/main/res/drawable/ic_notification_sealgate_safe.xml create mode 100644 app/src/main/res/layout/activity_execution_log.xml create mode 100644 app/src/main/res/layout/item_execution_log.xml create mode 100644 app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt 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..204e7bf 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_notification_sealgate_safe.xml b/app/src/main/res/drawable/ic_notification_sealgate_safe.xml new file mode 100644 index 0000000..87e704f --- /dev/null +++ b/app/src/main/res/drawable/ic_notification_sealgate_safe.xml @@ -0,0 +1,22 @@ + + + + + + + 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..7b73296 --- /dev/null +++ b/app/src/main/res/layout/activity_execution_log.xml @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + 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..37ffd9a --- /dev/null +++ b/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt @@ -0,0 +1,77 @@ +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.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ExecutionLogStoreTest { + @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) + } +} From 1e00f2af492dce6d74e1f6428aa1a0a073ce1d5b Mon Sep 17 00:00:00 2001 From: Eito Miyamura Date: Wed, 2 Sep 2026 17:11:50 +0100 Subject: [PATCH 2/2] Address review feedback --- .../main/res/drawable/ic_launcher_foreground.xml | 8 ++++---- app/src/main/res/drawable/ic_stat_sealgate.xml | 16 ---------------- .../main/res/layout/activity_execution_log.xml | 3 ++- .../ai/sealgate/stdiod/ExecutionLogStoreTest.kt | 4 ++++ 4 files changed, 10 insertions(+), 21 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_stat_sealgate.xml diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index 204e7bf..7e88c7b 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -1,7 +1,7 @@ + android:insetLeft="21dp" + android:insetTop="21dp" + android:insetRight="21dp" + android:insetBottom="21dp" /> diff --git a/app/src/main/res/drawable/ic_stat_sealgate.xml b/app/src/main/res/drawable/ic_stat_sealgate.xml deleted file mode 100644 index 308b4f2..0000000 --- a/app/src/main/res/drawable/ic_stat_sealgate.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - diff --git a/app/src/main/res/layout/activity_execution_log.xml b/app/src/main/res/layout/activity_execution_log.xml index 7b73296..6c831b5 100644 --- a/app/src/main/res/layout/activity_execution_log.xml +++ b/app/src/main/res/layout/activity_execution_log.xml @@ -9,8 +9,9 @@ diff --git a/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt b/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt index 37ffd9a..e495f62 100644 --- a/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt +++ b/app/src/test/java/ai/sealgate/stdiod/ExecutionLogStoreTest.kt @@ -6,12 +6,16 @@ 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()