Add Android app: jcode serve client for Pixel/Termux

ober

0156e2032c2c4d89fa912d83412dff72b7e0191e

diff --git a/.gitignore b/.gitignore
index 4ae9c86..6557c8d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -3,3 +3,9 @@
 /jcode
 .jerbuild-hashes
 jcode.json
+
+# Android build outputs
+/android/android.jar
+/android/build/
+/android/dist/
+/android/app/src/main/assets/native/
diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..7df7f3d
--- /dev/null
+++ b/android/app/src/main/AndroidManifest.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<manifest xmlns:android="http://schemas.android.com/apk/res/android"
+    package="dev.jerboa.jcode"
+    android:versionCode="1"
+    android:versionName="0.1.0"
+    android:installLocation="auto">
+
+    <uses-permission android:name="android.permission.INTERNET" />
+    <uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
+
+    <application
+        android:label="@string/app_name"
+        android:icon="@drawable/ic_launcher"
+        android:theme="@android:style/Theme.Material.Light.DarkActionBar"
+        android:networkSecurityConfig="@xml/network_security_config"
+        android:extractNativeLibs="true"
+        android:debuggable="true"
+        android:allowBackup="false">
+
+        <activity
+            android:name=".MainActivity"
+            android:exported="true"
+            android:configChanges="orientation|screenSize|keyboardHidden"
+            android:windowSoftInputMode="adjustResize">
+            <intent-filter>
+                <action android:name="android.intent.action.MAIN" />
+                <category android:name="android.intent.category.LAUNCHER" />
+            </intent-filter>
+        </activity>
+
+        <activity
+            android:name=".SettingsActivity"
+            android:exported="false"
+            android:label="@string/settings" />
+
+    </application>
+</manifest>
diff --git a/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt b/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt
new file mode 100644
index 0000000..e36b58e
--- /dev/null
+++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt
@@ -0,0 +1,280 @@
+package dev.jerboa.jcode
+
+import android.content.Context
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import org.json.JSONObject
+import java.io.BufferedReader
+import java.io.BufferedWriter
+import java.io.File
+import java.io.InputStreamReader
+import java.io.OutputStreamWriter
+import java.util.concurrent.LinkedBlockingQueue
+import java.util.concurrent.atomic.AtomicBoolean
+
+/**
+ * Manages a long-running `jcode serve` subprocess.
+ *
+ * The jcode binary ships inside the APK as lib/arm64-v8a/libjcode.so and is
+ * extracted to nativeLibraryDir at install time (extractNativeLibs=true in
+ * manifest). The rename is required because Android >=29 blocks exec from the
+ * app's regular data dir but allows exec from the native library dir, and
+ * anything in that dir must be named lib*.so for PackageManager to extract it.
+ *
+ * Protocol is newline-delimited JSON in both directions; see serve.ss in the
+ * Scheme sources for the event schema.
+ */
+class JcodeProcess(private val context: Context) {
+
+    interface Listener {
+        fun onReady(sessionId: String, title: String)
+        fun onToken(text: String)
+        fun onToolStart(name: String, argsJson: String)
+        fun onToolEnd(name: String)
+        fun onTurnEnd(sessionId: String)
+        fun onError(message: String)
+        fun onProcessDied(exitCode: Int, stderrTail: String)
+    }
+
+    private val main = Handler(Looper.getMainLooper())
+    private val outbound = LinkedBlockingQueue<String>()
+    private val alive = AtomicBoolean(false)
+    private val stderrTail = StringBuilder()
+
+    private var proc: Process? = null
+    private var writerThread: Thread? = null
+    private var readerThread: Thread? = null
+    private var stderrThread: Thread? = null
+
+    var listener: Listener? = null
+
+    fun isRunning(): Boolean = alive.get()
+
+    /**
+     * Spawn the subprocess. Throws if the binary is missing or exec fails.
+     * Safe to call multiple times; stops any previous process first.
+     */
+    fun start() {
+        stop()
+
+        val nativeDir = context.applicationInfo.nativeLibraryDir
+        val binary = File(nativeDir, "libjcode.so")
+        if (!binary.exists()) {
+            throw IllegalStateException(
+                "jcode binary not found at ${binary.absolutePath} — " +
+                        "APK may be missing jniLibs or extractNativeLibs is false"
+            )
+        }
+
+        // Extract bundled shared libs from assets/native/ → filesDir/native/.
+        // These are the Termux-originated deps the binary needs (ncursesw, iconv,
+        // sqlite3, z). Android's lib/<abi>/ mechanism only extracts files literally
+        // ending in .so, so suffix-versioned names like libncursesw.so.6 can't live
+        // there — we ship them in assets and copy them out on first launch.
+        val libDir = File(context.filesDir, "native")
+        extractNativeLibs(libDir)
+
+        val filesDir = context.filesDir
+        val pb = ProcessBuilder(binary.absolutePath, "serve")
+            .directory(filesDir)
+            .redirectErrorStream(false)
+
+        val ldPath = "${libDir.absolutePath}:$nativeDir"
+        val env = pb.environment()
+        env["HOME"] = filesDir.absolutePath
+        env["LD_LIBRARY_PATH"] = ldPath
+        env["TMPDIR"] = context.cacheDir.absolutePath
+        env["TERM"] = "dumb"
+        env["NO_COLOR"] = "1"
+
+        Log.i(TAG, "spawning ${binary.absolutePath}")
+        Log.i(TAG, "  cwd=${filesDir.absolutePath}")
+        Log.i(TAG, "  LD_LIBRARY_PATH=$ldPath")
+
+        val p = pb.start()
+        proc = p
+        alive.set(true)
+        stderrTail.clear()
+
+        writerThread = Thread { writerLoop(p) }.apply {
+            name = "jcode-writer"
+            isDaemon = true
+            start()
+        }
+        readerThread = Thread { readerLoop(p) }.apply {
+            name = "jcode-reader"
+            isDaemon = true
+            start()
+        }
+        stderrThread = Thread { stderrLoop(p) }.apply {
+            name = "jcode-stderr"
+            isDaemon = true
+            start()
+        }
+    }
+
+    /**
+     * Kill the subprocess if alive. Safe to call multiple times.
+     */
+    fun stop() {
+        alive.set(false)
+        proc?.let {
+            try {
+                it.destroy()
+            } catch (e: Exception) {
+                Log.w(TAG, "destroy failed", e)
+            }
+        }
+        proc = null
+        outbound.clear()
+    }
+
+    /**
+     * Send an event object to jcode. Non-blocking — enqueues on the writer
+     * thread's queue.
+     */
+    fun send(event: JSONObject) {
+        if (!alive.get()) {
+            Log.w(TAG, "send() while not alive, dropped: $event")
+            return
+        }
+        outbound.put(event.toString() + "\n")
+    }
+
+    fun sendUserMessage(text: String, mode: String) {
+        val ev = JSONObject()
+            .put("type", "user")
+            .put("text", text)
+            .put("mode", mode)
+        send(ev)
+    }
+
+    fun sendNewSession(title: String? = null) {
+        val ev = JSONObject().put("type", "new_session")
+        if (title != null) ev.put("title", title)
+        send(ev)
+    }
+
+    // ── internal ────────────────────────────────────────────────────────
+
+    /**
+     * Copy every file under assets/native/ into [libDir], overwriting if the
+     * source differs in size from the existing destination. Idempotent.
+     */
+    private fun extractNativeLibs(libDir: File) {
+        if (!libDir.exists()) libDir.mkdirs()
+        val am = context.assets
+        val files = am.list("native") ?: emptyArray()
+        for (name in files) {
+            val dest = File(libDir, name)
+            try {
+                am.open("native/$name").use { input ->
+                    val bytes = input.readBytes()
+                    if (dest.exists() && dest.length().toInt() == bytes.size) {
+                        // Good enough signature; skip rewrite.
+                        return@use
+                    }
+                    dest.writeBytes(bytes)
+                    Log.i(TAG, "extracted $name (${bytes.size} bytes)")
+                }
+            } catch (e: Exception) {
+                Log.w(TAG, "failed to extract $name", e)
+            }
+        }
+    }
+
+    private fun writerLoop(p: Process) {
+        val w = BufferedWriter(OutputStreamWriter(p.outputStream, Charsets.UTF_8))
+        try {
+            while (alive.get()) {
+                val line = outbound.take()
+                w.write(line)
+                w.flush()
+            }
+        } catch (e: InterruptedException) {
+            // expected on shutdown
+        } catch (e: Exception) {
+            Log.w(TAG, "writer failed", e)
+        } finally {
+            try { w.close() } catch (_: Exception) {}
+        }
+    }
+
+    private fun readerLoop(p: Process) {
+        val r = BufferedReader(InputStreamReader(p.inputStream, Charsets.UTF_8))
+        try {
+            while (alive.get()) {
+                val line = r.readLine() ?: break
+                if (line.isEmpty()) continue
+                try {
+                    val obj = JSONObject(line)
+                    dispatch(obj)
+                } catch (e: Exception) {
+                    Log.w(TAG, "bad JSON line: $line", e)
+                }
+            }
+        } catch (e: Exception) {
+            Log.w(TAG, "reader failed", e)
+        } finally {
+            try { r.close() } catch (_: Exception) {}
+            handleProcessDeath(p)
+        }
+    }
+
+    private fun stderrLoop(p: Process) {
+        val r = BufferedReader(InputStreamReader(p.errorStream, Charsets.UTF_8))
+        try {
+            while (alive.get()) {
+                val line = r.readLine() ?: break
+                Log.i("jcode-stderr", line)
+                synchronized(stderrTail) {
+                    stderrTail.append(line).append('\n')
+                    // Keep last ~4kb for crash reporting
+                    if (stderrTail.length > 4096) {
+                        stderrTail.delete(0, stderrTail.length - 4096)
+                    }
+                }
+            }
+        } catch (e: Exception) {
+            Log.w(TAG, "stderr reader failed", e)
+        } finally {
+            try { r.close() } catch (_: Exception) {}
+        }
+    }
+
+    private fun handleProcessDeath(p: Process) {
+        if (!alive.getAndSet(false)) return
+        val exit = try { p.waitFor() } catch (_: Exception) { -1 }
+        val tail = synchronized(stderrTail) { stderrTail.toString() }
+        Log.w(TAG, "jcode exited code=$exit")
+        main.post { listener?.onProcessDied(exit, tail) }
+    }
+
+    private fun dispatch(obj: JSONObject) {
+        val type = obj.optString("type", "")
+        main.post {
+            val l = listener ?: return@post
+            when (type) {
+                "ready" -> l.onReady(
+                    obj.optString("session_id", ""),
+                    obj.optString("session_title", "")
+                )
+                "token" -> l.onToken(obj.optString("text", ""))
+                "tool_start" -> l.onToolStart(
+                    obj.optString("name", "?"),
+                    obj.optJSONObject("args")?.toString() ?: "{}"
+                )
+                "tool_end" -> l.onToolEnd(obj.optString("name", "?"))
+                "turn_end" -> l.onTurnEnd(obj.optString("session_id", ""))
+                "error" -> l.onError(obj.optString("message", "unknown error"))
+                "pong" -> {} // silent heartbeat ack
+                else -> Log.d(TAG, "unhandled event type: $type")
+            }
+        }
+    }
+
+    companion object {
+        private const val TAG = "JcodeProcess"
+    }
+}
diff --git a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
new file mode 100644
index 0000000..16e954c
--- /dev/null
+++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt
@@ -0,0 +1,260 @@
+package dev.jerboa.jcode
+
+import android.app.Activity
+import android.content.Intent
+import android.os.Bundle
+import android.text.Editable
+import android.util.Log
+import android.view.View
+import android.view.inputmethod.EditorInfo
+import android.widget.Button
+import android.widget.EditText
+import android.widget.ImageButton
+import android.widget.LinearLayout
+import android.widget.ScrollView
+import android.widget.Switch
+import android.widget.TextView
+import android.widget.Toast
+import java.io.File
+
+/**
+ * Chat UI for jcode. Spawns a `jcode serve` subprocess, wires its JSONL events
+ * to dynamically-appended bubble views in a ScrollView, and sends user input
+ * with the current mode (plan/build).
+ *
+ * Chat container is a plain LinearLayout inside a ScrollView — no RecyclerView
+ * so we avoid the androidx dependency (which is painful without gradle).
+ */
+class MainActivity : Activity(), JcodeProcess.Listener {
+
+    private lateinit var titleText: TextView
+    private lateinit var modeLabel: TextView
+    private lateinit var modeSwitch: Switch
+    private lateinit var btnNewSession: ImageButton
+    private lateinit var btnSettings: ImageButton
+    private lateinit var chatScroll: ScrollView
+    private lateinit var chatContainer: LinearLayout
+    private lateinit var statusLine: TextView
+    private lateinit var inputText: EditText
+    private lateinit var btnSend: Button
+
+    private lateinit var jcode: JcodeProcess
+
+    /** TextView of the in-progress assistant bubble, or null when idle. */
+    private var currentAssistantText: TextView? = null
+
+    /** StringBuilder accumulating tokens for the in-progress bubble. */
+    private val currentAssistantBuf = StringBuilder()
+
+    private var sending = false
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_main)
+
+        titleText = findViewById(R.id.title_text)
+        modeLabel = findViewById(R.id.mode_label)
+        modeSwitch = findViewById(R.id.mode_switch)
+        btnNewSession = findViewById(R.id.btn_new_session)
+        btnSettings = findViewById(R.id.btn_settings)
+        chatScroll = findViewById(R.id.chat_scroll)
+        chatContainer = findViewById(R.id.chat_container)
+        statusLine = findViewById(R.id.status_line)
+        inputText = findViewById(R.id.input_text)
+        btnSend = findViewById(R.id.btn_send)
+
+        modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) }
+        updateModeLabel(modeSwitch.isChecked)
+
+        btnSettings.setOnClickListener {
+            startActivity(Intent(this, SettingsActivity::class.java))
+        }
+
+        btnNewSession.setOnClickListener {
+            if (!jcode.isRunning()) return@setOnClickListener
+            chatContainer.removeAllViews()
+            currentAssistantText = null
+            currentAssistantBuf.setLength(0)
+            jcode.sendNewSession()
+            setStatus(getString(R.string.status_ready))
+        }
+
+        btnSend.setOnClickListener { trySend() }
+        inputText.setOnEditorActionListener { _, actionId, _ ->
+            if (actionId == EditorInfo.IME_ACTION_SEND) {
+                trySend()
+                true
+            } else false
+        }
+
+        jcode = JcodeProcess(this)
+        jcode.listener = this
+    }
+
+    override fun onStart() {
+        super.onStart()
+
+        // If no keys yet, push the user to settings before we try to spawn.
+        val keys = File(filesDir, "jcode.json")
+        if (!keys.exists()) {
+            Toast.makeText(this, R.string.no_keys, Toast.LENGTH_LONG).show()
+            startActivity(Intent(this, SettingsActivity::class.java))
+            return
+        }
+
+        if (!jcode.isRunning()) {
+            try {
+                jcode.start()
+                setStatus("Starting…")
+            } catch (e: Exception) {
+                Log.e(TAG, "spawn failed", e)
+                appendError("Failed to spawn jcode: ${e.message}")
+            }
+        }
+    }
+
+    override fun onDestroy() {
+        super.onDestroy()
+        jcode.stop()
+    }
+
+    // ── UI helpers ──────────────────────────────────────────────────────
+
+    private fun updateModeLabel(build: Boolean) {
+        modeLabel.setText(if (build) R.string.mode_build else R.string.mode_plan)
+        val color = resources.getColor(
+            if (build) R.color.mode_build else R.color.mode_plan, theme
+        )
+        modeLabel.setTextColor(color)
+    }
+
+    private fun currentMode(): String = if (modeSwitch.isChecked) "build" else "plan"
+
+    private fun setStatus(text: String) {
+        statusLine.text = text
+        statusLine.visibility = View.VISIBLE
+    }
+
+    private fun hideStatus() {
+        statusLine.visibility = View.GONE
+    }
+
+    private fun scrollToBottom() {
+        chatScroll.post { chatScroll.fullScroll(View.FOCUS_DOWN) }
+    }
+
+    private fun appendUser(text: String) {
+        val view = layoutInflater.inflate(R.layout.item_chat_user, chatContainer, false)
+        view.findViewById<TextView>(R.id.message_text).text = text
+        chatContainer.addView(view)
+        scrollToBottom()
+    }
+
+    private fun beginAssistant() {
+        val view = layoutInflater.inflate(R.layout.item_chat_assistant, chatContainer, false)
+        val tv = view.findViewById<TextView>(R.id.message_text)
+        tv.text = ""
+        chatContainer.addView(view)
+        currentAssistantText = tv
+        currentAssistantBuf.setLength(0)
+        scrollToBottom()
+    }
+
+    private fun appendAssistantToken(token: String) {
+        val tv = currentAssistantText ?: run {
+            beginAssistant()
+            currentAssistantText!!
+        }
+        currentAssistantBuf.append(token)
+        tv.text = currentAssistantBuf.toString()
+        scrollToBottom()
+    }
+
+    private fun finalizeAssistant() {
+        currentAssistantText = null
+        currentAssistantBuf.setLength(0)
+    }
+
+    private fun appendTool(name: String, argsJson: String) {
+        val view = layoutInflater.inflate(R.layout.item_chat_tool, chatContainer, false)
+        view.findViewById<TextView>(R.id.tool_header).text = "⚙ $name"
+        view.findViewById<TextView>(R.id.tool_args).text = argsJson
+        chatContainer.addView(view)
+        scrollToBottom()
+    }
+
+    private fun appendError(msg: String) {
+        val view = layoutInflater.inflate(R.layout.item_chat_error, chatContainer, false)
+        view.findViewById<TextView>(R.id.error_text).text = msg
+        chatContainer.addView(view)
+        scrollToBottom()
+    }
+
+    // ── send ────────────────────────────────────────────────────────────
+
+    private fun trySend() {
+        if (sending) return
+        val text = inputText.text.toString().trim()
+        if (text.isEmpty()) return
+        if (!jcode.isRunning()) {
+            appendError("jcode is not running — try restarting the app")
+            return
+        }
+
+        sending = true
+        btnSend.isEnabled = false
+        appendUser(text)
+        beginAssistant()
+        setStatus(getString(R.string.status_thinking))
+        jcode.sendUserMessage(text, currentMode())
+        inputText.text = Editable.Factory.getInstance().newEditable("")
+    }
+
+    // ── JcodeProcess.Listener (main thread) ─────────────────────────────
+
+    override fun onReady(sessionId: String, title: String) {
+        if (title.isNotEmpty()) titleText.text = title
+        setStatus("Ready · session ${sessionId.take(8)}")
+    }
+
+    override fun onToken(text: String) {
+        appendAssistantToken(text)
+    }
+
+    override fun onToolStart(name: String, argsJson: String) {
+        appendTool(name, argsJson)
+        setStatus(getString(R.string.status_running_tool, name))
+    }
+
+    override fun onToolEnd(name: String) {
+        setStatus(getString(R.string.status_thinking))
+    }
+
+    override fun onTurnEnd(sessionId: String) {
+        finalizeAssistant()
+        hideStatus()
+        sending = false
+        btnSend.isEnabled = true
+    }
+
+    override fun onError(message: String) {
+        appendError(message)
+        finalizeAssistant()
+        hideStatus()
+        sending = false
+        btnSend.isEnabled = true
+    }
+
+    override fun onProcessDied(exitCode: Int, stderrTail: String) {
+        val tailShort = if (stderrTail.length > 800) stderrTail.takeLast(800) else stderrTail
+        appendError("jcode died (exit=$exitCode)\n$tailShort")
+        finalizeAssistant()
+        hideStatus()
+        sending = false
+        btnSend.isEnabled = true
+    }
+
+    companion object {
+        private const val TAG = "jcode.Main"
+    }
+}
diff --git a/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt b/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt
new file mode 100644
index 0000000..4218124
--- /dev/null
+++ b/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt
@@ -0,0 +1,108 @@
+package dev.jerboa.jcode
+
+import android.app.Activity
+import android.content.Intent
+import android.net.Uri
+import android.os.Bundle
+import android.util.Log
+import android.widget.Button
+import android.widget.TextView
+import android.widget.Toast
+import org.json.JSONObject
+import java.io.File
+
+/**
+ * Settings: pick a jcode.json file from storage and copy it into
+ * filesDir/jcode.json. Also shows the working directory (filesDir).
+ */
+class SettingsActivity : Activity() {
+
+    private lateinit var keysStatus: TextView
+    private lateinit var workdirText: TextView
+    private lateinit var btnPickKeys: Button
+
+    override fun onCreate(savedInstanceState: Bundle?) {
+        super.onCreate(savedInstanceState)
+        setContentView(R.layout.activity_settings)
+
+        keysStatus = findViewById(R.id.keys_status)
+        workdirText = findViewById(R.id.workdir_text)
+        btnPickKeys = findViewById(R.id.btn_pick_keys)
+
+        workdirText.text = filesDir.absolutePath
+
+        btnPickKeys.setOnClickListener {
+            val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+                addCategory(Intent.CATEGORY_OPENABLE)
+                type = "*/*"
+            }
+            startActivityForResult(intent, REQ_PICK_KEYS)
+        }
+
+        refreshKeysStatus()
+    }
+
+    override fun onResume() {
+        super.onResume()
+        refreshKeysStatus()
+    }
+
+    private fun refreshKeysStatus() {
+        val f = File(filesDir, "jcode.json")
+        if (!f.exists()) {
+            keysStatus.text = getString(R.string.no_keys)
+            return
+        }
+        // Parse and show which providers are configured, without leaking key material.
+        try {
+            val text = f.readText(Charsets.UTF_8)
+            val obj = JSONObject(text)
+            val providers = mutableListOf<String>()
+            val iter = obj.keys()
+            while (iter.hasNext()) {
+                val k = iter.next()
+                val v = obj.opt(k)
+                if (v is String && v.isNotEmpty()) providers.add(k)
+            }
+            keysStatus.text = if (providers.isEmpty()) {
+                "jcode.json present but has no keys"
+            } else {
+                "API keys loaded: ${providers.joinToString(", ")}"
+            }
+        } catch (e: Exception) {
+            keysStatus.text = "jcode.json present but not valid JSON: ${e.message}"
+        }
+    }
+
+    override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
+        super.onActivityResult(requestCode, resultCode, data)
+        if (requestCode != REQ_PICK_KEYS || resultCode != RESULT_OK) return
+        val uri: Uri = data?.data ?: return
+
+        try {
+            contentResolver.openInputStream(uri).use { input ->
+                if (input == null) {
+                    Toast.makeText(this, "Could not open file", Toast.LENGTH_SHORT).show()
+                    return
+                }
+                val bytes = input.readBytes()
+                // Sanity-check: must be valid JSON.
+                val text = String(bytes, Charsets.UTF_8)
+                JSONObject(text)  // throws on bad JSON
+
+                val dest = File(filesDir, "jcode.json")
+                dest.writeBytes(bytes)
+                Toast.makeText(this, R.string.keys_loaded, Toast.LENGTH_SHORT).show()
+                refreshKeysStatus()
+            }
+        } catch (e: Exception) {
+            Log.e(TAG, "copy failed", e)
+            Toast.makeText(this, "Import failed: ${e.message}", Toast.LENGTH_LONG).show()
+        }
+    }
+
+    companion object {
+        private const val TAG = "jcode.Settings"
+        private const val REQ_PICK_KEYS = 1001
+    }
+}
diff --git a/android/app/src/main/res/drawable/ic_launcher.xml b/android/app/src/main/res/drawable/ic_launcher.xml
new file mode 100644
index 0000000..c134ffc
--- /dev/null
+++ b/android/app/src/main/res/drawable/ic_launcher.xml
@@ -0,0 +1,13 @@
+<?xml version="1.0" encoding="utf-8"?>
+<vector xmlns:android="http://schemas.android.com/apk/res/android"
+    android:width="48dp"
+    android:height="48dp"
+    android:viewportWidth="48"
+    android:viewportHeight="48">
+    <path
+        android:fillColor="#FF0E1117"
+        android:pathData="M4,4h40v40H4z" />
+    <path
+        android:fillColor="#FF58A6FF"
+        android:pathData="M12,12 L20,12 L20,30 Q20,34 16,34 L12,34 L12,30 L16,30 L16,12z M22,12 L28,12 L32,22 L32,12 L36,12 L36,34 L30,34 L26,24 L26,34 L22,34z" />
+</vector>
diff --git a/android/app/src/main/res/layout/activity_main.xml b/android/app/src/main/res/layout/activity_main.xml
new file mode 100644
index 0000000..b5e9634
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_main.xml
@@ -0,0 +1,131 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="@color/bg_dark">
+
+    <!-- Header: title, mode switch, new session, settings -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="56dp"
+        android:background="@color/bg_card"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        android:paddingStart="16dp"
+        android:paddingEnd="8dp">
+
+        <TextView
+            android:id="@+id/title_text"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:text="@string/app_name"
+            android:textColor="@color/text_primary"
+            android:textSize="18sp"
+            android:textStyle="bold" />
+
+        <TextView
+            android:id="@+id/mode_label"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="4dp"
+            android:text="@string/mode_build"
+            android:textColor="@color/mode_build"
+            android:textSize="12sp"
+            android:textStyle="bold" />
+
+        <Switch
+            android:id="@+id/mode_switch"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginEnd="4dp"
+            android:checked="true" />
+
+        <ImageButton
+            android:id="@+id/btn_new_session"
+            android:layout_width="40dp"
+            android:layout_height="40dp"
+            android:background="?android:attr/selectableItemBackgroundBorderless"
+            android:contentDescription="@string/new_session"
+            android:src="@android:drawable/ic_menu_add" />
+
+        <ImageButton
+            android:id="@+id/btn_settings"
+            android:layout_width="40dp"
+            android:layout_height="40dp"
+            android:background="?android:attr/selectableItemBackgroundBorderless"
+            android:contentDescription="@string/settings"
+            android:src="@android:drawable/ic_menu_preferences" />
+    </LinearLayout>
+
+    <!-- Chat transcript -->
+    <ScrollView
+        android:id="@+id/chat_scroll"
+        android:layout_width="match_parent"
+        android:layout_height="0dp"
+        android:layout_weight="1"
+        android:fillViewport="true"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp"
+        android:scrollbars="vertical">
+
+        <LinearLayout
+            android:id="@+id/chat_container"
+            android:layout_width="match_parent"
+            android:layout_height="wrap_content"
+            android:orientation="vertical" />
+    </ScrollView>
+
+    <!-- Status line (shown during tool calls / errors) -->
+    <TextView
+        android:id="@+id/status_line"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bg_card"
+        android:paddingStart="16dp"
+        android:paddingEnd="16dp"
+        android:paddingTop="4dp"
+        android:paddingBottom="4dp"
+        android:text="@string/status_ready"
+        android:textColor="@color/text_secondary"
+        android:textSize="11sp"
+        android:visibility="gone" />
+
+    <!-- Input row -->
+    <LinearLayout
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bg_card"
+        android:gravity="center_vertical"
+        android:orientation="horizontal"
+        android:paddingStart="12dp"
+        android:paddingEnd="12dp"
+        android:paddingTop="8dp"
+        android:paddingBottom="8dp">
+
+        <EditText
+            android:id="@+id/input_text"
+            android:layout_width="0dp"
+            android:layout_height="wrap_content"
+            android:layout_weight="1"
+            android:background="@color/bg_input"
+            android:hint="@string/hint_message"
+            android:inputType="textMultiLine|textCapSentences"
+            android:maxLines="5"
+            android:padding="12dp"
+            android:textColor="@color/text_primary"
+            android:textColorHint="@color/text_muted"
+            android:textSize="14sp" />
+
+        <Button
+            android:id="@+id/btn_send"
+            android:layout_width="wrap_content"
+            android:layout_height="wrap_content"
+            android:layout_marginStart="8dp"
+            android:backgroundTint="@color/accent"
+            android:text="@string/send"
+            android:textColor="@color/white" />
+    </LinearLayout>
+
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/activity_settings.xml b/android/app/src/main/res/layout/activity_settings.xml
new file mode 100644
index 0000000..e96a2d9
--- /dev/null
+++ b/android/app/src/main/res/layout/activity_settings.xml
@@ -0,0 +1,79 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="match_parent"
+    android:orientation="vertical"
+    android:background="@color/bg_dark"
+    android:padding="20dp">
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="API Keys"
+        android:textColor="@color/text_primary"
+        android:textSize="18sp"
+        android:textStyle="bold" />
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="4dp"
+        android:text="Pick a jcode.json file containing your API keys. It will be copied into the app's private storage."
+        android:textColor="@color/text_secondary"
+        android:textSize="12sp" />
+
+    <TextView
+        android:id="@+id/keys_status"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        android:fontFamily="monospace"
+        android:padding="12dp"
+        android:background="@color/bg_card"
+        android:textColor="@color/text_primary"
+        android:textSize="12sp" />
+
+    <Button
+        android:id="@+id/btn_pick_keys"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        android:backgroundTint="@color/accent"
+        android:text="@string/pick_keys"
+        android:textColor="@color/white" />
+
+    <View
+        android:layout_width="match_parent"
+        android:layout_height="1dp"
+        android:layout_marginTop="24dp"
+        android:layout_marginBottom="24dp"
+        android:background="@color/bg_card" />
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:text="Working Directory"
+        android:textColor="@color/text_primary"
+        android:textSize="18sp"
+        android:textStyle="bold" />
+
+    <TextView
+        android:id="@+id/workdir_text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="12dp"
+        android:fontFamily="monospace"
+        android:padding="12dp"
+        android:background="@color/bg_card"
+        android:textColor="@color/text_primary"
+        android:textSize="11sp" />
+
+    <TextView
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:layout_marginTop="4dp"
+        android:text="Tools (read/write/bash) operate inside this directory. It is the app's private files dir, not accessible to other apps."
+        android:textColor="@color/text_secondary"
+        android:textSize="12sp" />
+
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/item_chat_assistant.xml b/android/app/src/main/res/layout/item_chat_assistant.xml
new file mode 100644
index 0000000..f4ef8c5
--- /dev/null
+++ b/android/app/src/main/res/layout/item_chat_assistant.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingStart="12dp"
+    android:paddingEnd="48dp"
+    android:paddingTop="4dp"
+    android:paddingBottom="4dp">
+
+    <TextView
+        android:id="@+id/message_text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bubble_assistant"
+        android:fontFamily="monospace"
+        android:padding="12dp"
+        android:textColor="@color/text_primary"
+        android:textIsSelectable="true"
+        android:textSize="13sp" />
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/item_chat_error.xml b/android/app/src/main/res/layout/item_chat_error.xml
new file mode 100644
index 0000000..9580e20
--- /dev/null
+++ b/android/app/src/main/res/layout/item_chat_error.xml
@@ -0,0 +1,21 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingStart="12dp"
+    android:paddingEnd="12dp"
+    android:paddingTop="4dp"
+    android:paddingBottom="4dp">
+
+    <TextView
+        android:id="@+id/error_text"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bubble_error"
+        android:fontFamily="monospace"
+        android:padding="12dp"
+        android:textColor="@color/white"
+        android:textIsSelectable="true"
+        android:textSize="12sp" />
+</LinearLayout>
diff --git a/android/app/src/main/res/layout/item_chat_tool.xml b/android/app/src/main/res/layout/item_chat_tool.xml
new file mode 100644
index 0000000..c3e5d75
--- /dev/null
+++ b/android/app/src/main/res/layout/item_chat_tool.xml
@@ -0,0 +1,37 @@
+<?xml version="1.0" encoding="utf-8"?>
+<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
+    android:layout_width="match_parent"
+    android:layout_height="wrap_content"
+    android:orientation="vertical"
+    android:paddingStart="24dp"
+    android:paddingEnd="24dp"
+    android:paddingTop="4dp"
+    android:paddingBottom="4dp">
+
+    <TextView
+        android:id="@+id/tool_header"
+        android:layout_width="match_parent"
+        android:layout_height="wrap_content"
+        android:background="@color/bubble_tool"
+        android:fontFamily="monospace"
+        android:paddingStart="10dp"
+        android:paddingEnd="10dp"