Add Android app: jcode serve client for Pixel/Termux
ober
0156e2032c2c4d89fa912d83412dff72b7e0191e
--- 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/ new file mode 100644 --- /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> new file mode 100644 --- /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" + } +} new file mode 100644 --- /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" + } +} new file mode 100644 --- /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 + } +} new file mode 100644 --- /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> new file mode 100644 --- /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> new file mode 100644 --- /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> new file mode 100644 --- /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> new file mode 100644 --- /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> new file mode 100644 --- /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"