Implement client-server architecture for Android app
ober
33a046f55e0e673484b37bba72ea2c69d73ed8d2
--- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,8 +1,8 @@ <?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:versionCode="2" + android:versionName="0.2.0" android:installLocation="auto"> <uses-permission android:name="android.permission.INTERNET" /> @@ -13,7 +13,6 @@ 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"> new file mode 100644 --- /dev/null +++ b/android/app/src/main/java/dev/jerboa/jcode/JcodeClient.kt @@ -0,0 +1,289 @@ +package dev.jerboa.jcode + +import android.content.Context +import android.content.SharedPreferences +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.InputStreamReader +import java.io.OutputStreamWriter +import java.net.Socket +import java.util.concurrent.LinkedBlockingQueue +import java.util.concurrent.atomic.AtomicBoolean + +/** + * TCP socket client for connecting to `jcode serve --port PORT` in Termux. + * + * Replaces JcodeProcess (which spawned an embedded binary). The jcode server + * runs in Termux with full environment access; this client connects over + * localhost and communicates via the same JSONL protocol. + * + * Auth handshake: on connect, sends {"type":"auth","token":"<token>"} and + * waits for {"type":"auth_ok"}. On failure, reports to listener and closes. + */ +class JcodeClient(private val context: Context) { + + interface Listener { + fun onConnected() + 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 onDisconnected(reason: String) + fun onAuthFailed(message: String) + } + + private val main = Handler(Looper.getMainLooper()) + private val outbound = LinkedBlockingQueue<String>() + private val connected = AtomicBoolean(false) + private val shouldReconnect = AtomicBoolean(false) + + private var socket: Socket? = null + private var writerThread: Thread? = null + private var readerThread: Thread? = null + private var reconnectThread: Thread? = null + + var listener: Listener? = null + + fun isConnected(): Boolean = connected.get() + + /** + * Read connection settings from SharedPreferences. + */ + private fun prefs(): SharedPreferences = + context.getSharedPreferences("jcode_settings", Context.MODE_PRIVATE) + + fun getHost(): String = prefs().getString("host", "127.0.0.1") ?: "127.0.0.1" + fun getPort(): Int = prefs().getInt("port", 8321) + fun getToken(): String = prefs().getString("token", "") ?: "" + + /** + * Connect to the jcode server. Runs the connection attempt on a background + * thread. Calls listener on the main thread. + */ + fun connect() { + disconnect() + shouldReconnect.set(true) + + val host = getHost() + val port = getPort() + val token = getToken() + + if (token.isEmpty()) { + main.post { listener?.onAuthFailed("No auth token configured. Open Settings and paste the token from Termux.") } + return + } + + Thread { + try { + Log.i(TAG, "connecting to $host:$port") + val s = Socket(host, port) + socket = s + connected.set(true) + + val reader = BufferedReader(InputStreamReader(s.getInputStream(), Charsets.UTF_8)) + val writer = BufferedWriter(OutputStreamWriter(s.getOutputStream(), Charsets.UTF_8)) + + // Send auth handshake + val authMsg = JSONObject() + .put("type", "auth") + .put("token", token) + writer.write(authMsg.toString() + "\n") + writer.flush() + + // Wait for auth response + val responseLine = reader.readLine() + if (responseLine == null) { + connected.set(false) + main.post { listener?.onDisconnected("Server closed connection during auth") } + s.close() + return@Thread + } + + val response = JSONObject(responseLine) + val type = response.optString("type", "") + + if (type == "error") { + connected.set(false) + val msg = response.optString("message", "auth failed") + main.post { listener?.onAuthFailed(msg) } + s.close() + return@Thread + } + + if (type != "auth_ok") { + connected.set(false) + main.post { listener?.onAuthFailed("Unexpected response: $type") } + s.close() + return@Thread + } + + // Auth succeeded + Log.i(TAG, "authenticated to $host:$port") + main.post { listener?.onConnected() } + + // Start writer thread + writerThread = Thread { writerLoop(writer) }.apply { + name = "jcode-writer" + isDaemon = true + start() + } + + // Reader loop runs on this thread + readerLoop(reader) + + } catch (e: Exception) { + Log.w(TAG, "connect failed", e) + connected.set(false) + main.post { listener?.onDisconnected("Connection failed: ${e.message}") } + scheduleReconnect() + } + }.apply { + name = "jcode-connect" + isDaemon = true + start() + } + } + + /** + * Disconnect from the server. Safe to call multiple times. + */ + fun disconnect() { + shouldReconnect.set(false) + connected.set(false) + outbound.clear() + reconnectThread?.interrupt() + reconnectThread = null + socket?.let { + try { it.close() } catch (_: Exception) {} + } + socket = null + } + + /** + * Send a JSON event to the server. Non-blocking. + */ + fun send(event: JSONObject) { + if (!connected.get()) { + Log.w(TAG, "send() while not connected, 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) + } + + fun sendPing() { + send(JSONObject().put("type", "ping")) + } + + // ── internal ──────────────────────────────────────────────────────── + + private fun writerLoop(writer: BufferedWriter) { + try { + while (connected.get()) { + val line = outbound.take() + writer.write(line) + writer.flush() + } + } catch (_: InterruptedException) { + // expected on shutdown + } catch (e: Exception) { + Log.w(TAG, "writer failed", e) + } + } + + private fun readerLoop(reader: BufferedReader) { + try { + while (connected.get()) { + val line = reader.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 { + if (connected.getAndSet(false)) { + main.post { listener?.onDisconnected("Server disconnected") } + scheduleReconnect() + } + } + } + + 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") + } + } + } + + /** + * Reconnect with exponential backoff: 1s, 2s, 4s, max 10s. + */ + private fun scheduleReconnect() { + if (!shouldReconnect.get()) return + + reconnectThread = Thread { + var delay = 1000L + while (shouldReconnect.get() && !connected.get()) { + try { + Log.i(TAG, "reconnecting in ${delay}ms") + Thread.sleep(delay) + if (shouldReconnect.get() && !connected.get()) { + connect() + return@Thread + } + } catch (_: InterruptedException) { + return@Thread + } + delay = minOf(delay * 2, 10_000L) + } + }.apply { + name = "jcode-reconnect" + isDaemon = true + start() + } + } + + companion object { + private const val TAG = "JcodeClient" + } +} deleted file mode 100644 --- a/android/app/src/main/java/dev/jerboa/jcode/JcodeProcess.kt +++ /dev/null @@ -1,280 +0,0 @@ -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" - } -} --- a/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/MainActivity.kt @@ -1,6 +1,7 @@ package dev.jerboa.jcode import android.app.Activity +import android.content.Context import android.content.Intent import android.os.Bundle import android.text.Editable @@ -15,17 +16,13 @@ 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). + * Chat UI for jcode. Connects to `jcode serve --port PORT` running in Termux + * over a localhost TCP socket, wires JSONL events to dynamically-appended + * bubble views in a ScrollView. */ -class MainActivity : Activity(), JcodeProcess.Listener { +class MainActivity : Activity(), JcodeClient.Listener { private lateinit var titleText: TextView private lateinit var modeLabel: TextView @@ -37,8 +34,11 @@ class MainActivity : Activity(), JcodeProcess.Listener { private lateinit var statusLine: TextView private lateinit var inputText: EditText private lateinit var btnSend: Button + private lateinit var connectionBanner: LinearLayout + private lateinit var bannerText: TextView + private lateinit var btnReconnect: Button - private lateinit var jcode: JcodeProcess + private lateinit var client: JcodeClient /** TextView of the in-progress assistant bubble, or null when idle. */ private var currentAssistantText: TextView? = null @@ -62,6 +62,9 @@ class MainActivity : Activity(), JcodeProcess.Listener { statusLine = findViewById(R.id.status_line) inputText = findViewById(R.id.input_text) btnSend = findViewById(R.id.btn_send) + connectionBanner = findViewById(R.id.connection_banner) + bannerText = findViewById(R.id.banner_text) + btnReconnect = findViewById(R.id.btn_reconnect) modeSwitch.setOnCheckedChangeListener { _, checked -> updateModeLabel(checked) } updateModeLabel(modeSwitch.isChecked) @@ -71,11 +74,11 @@ class MainActivity : Activity(), JcodeProcess.Listener { } btnNewSession.setOnClickListener { - if (!jcode.isRunning()) return@setOnClickListener + if (!client.isConnected()) return@setOnClickListener chatContainer.removeAllViews() currentAssistantText = null currentAssistantBuf.setLength(0) - jcode.sendNewSession() + client.sendNewSession() setStatus(getString(R.string.status_ready)) } @@ -87,35 +90,38 @@ class MainActivity : Activity(), JcodeProcess.Listener { } else false } - jcode = JcodeProcess(this) - jcode.listener = this + btnReconnect.setOnClickListener { + client.connect() + showBanner("Connecting...") + } + + client = JcodeClient(this) + client.listener = this + + // Show disconnected state initially + showBanner("Not connected \u2014 start jcode serve in Termux") + setInputEnabled(false) } 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)) + val token = client.getToken() + if (token.isEmpty()) { + showBanner("No token configured \u2014 open Settings") + Toast.makeText(this, "Configure server connection in Settings", Toast.LENGTH_LONG).show() 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}") - } + if (!client.isConnected()) { + showBanner("Connecting...") + client.connect() } } override fun onDestroy() { super.onDestroy() - jcode.stop() + client.disconnect() } // ── UI helpers ────────────────────────────────────────────────────── @@ -139,6 +145,20 @@ class MainActivity : Activity(), JcodeProcess.Listener { statusLine.visibility = View.GONE } + private fun showBanner(text: String) { + bannerText.text = text + connectionBanner.visibility = View.VISIBLE + } + + private fun hideBanner() { + connectionBanner.visibility = View.GONE + } + + private fun setInputEnabled(enabled: Boolean) { + inputText.isEnabled = enabled + btnSend.isEnabled = enabled && !sending + } + private fun scrollToBottom() { chatScroll.post { chatScroll.fullScroll(View.FOCUS_DOWN) } } @@ -177,7 +197,7 @@ class MainActivity : Activity(), JcodeProcess.Listener { 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_header).text = "\u2699 $name" view.findViewById<TextView>(R.id.tool_args).text = argsJson chatContainer.addView(view) scrollToBottom() @@ -196,8 +216,8 @@ class MainActivity : Activity(), JcodeProcess.Listener { 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") + if (!client.isConnected()) { + appendError("Not connected \u2014 is jcode serve running in Termux?") return } @@ -206,15 +226,21 @@ class MainActivity : Activity(), JcodeProcess.Listener { appendUser(text) beginAssistant() setStatus(getString(R.string.status_thinking)) - jcode.sendUserMessage(text, currentMode()) + client.sendUserMessage(text, currentMode()) inputText.text = Editable.Factory.getInstance().newEditable("") } - // ── JcodeProcess.Listener (main thread) ───────────────────────────── + // ── JcodeClient.Listener (main thread) ────────────────────────────── + + override fun onConnected() { + hideBanner() + setInputEnabled(true) + setStatus("Connected") + } override fun onReady(sessionId: String, title: String) { if (title.isNotEmpty()) titleText.text = title - setStatus("Ready · session ${sessionId.take(8)}") + setStatus("Ready \u00b7 session ${sessionId.take(8)}") } override fun onToken(text: String) { @@ -245,13 +271,18 @@ class MainActivity : Activity(), JcodeProcess.Listener { 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") + override fun onDisconnected(reason: String) { + showBanner("Disconnected \u2014 $reason") + setInputEnabled(false) finalizeAssistant() hideStatus() sending = false - btnSend.isEnabled = true + } + + override fun onAuthFailed(message: String) { + showBanner("Auth failed \u2014 $message") + setInputEnabled(false) + appendError("Authentication failed: $message") } companion object { --- a/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt +++ b/android/app/src/main/java/dev/jerboa/jcode/SettingsActivity.kt @@ -1,108 +1,95 @@ package dev.jerboa.jcode import android.app.Activity -import android.content.Intent -import android.net.Uri +import android.content.Context +import android.content.SharedPreferences import android.os.Bundle -import android.util.Log import android.widget.Button +import android.widget.EditText 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). + * Settings: configure the jcode server connection (host, port, auth token). + * Keys are stored in SharedPreferences "jcode_settings". */ class SettingsActivity : Activity() { - private lateinit var keysStatus: TextView - private lateinit var workdirText: TextView - private lateinit var btnPickKeys: Button + private lateinit var hostInput: EditText + private lateinit var portInput: EditText + private lateinit var tokenInput: EditText + private lateinit var statusText: TextView + private lateinit var btnSave: Button + + private fun prefs(): SharedPreferences = + getSharedPreferences("jcode_settings", Context.MODE_PRIVATE) 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 + hostInput = findViewById(R.id.input_host) + portInput = findViewById(R.id.input_port) + tokenInput = findViewById(R.id.input_token) + statusText = findViewById(R.id.connection_status) + btnSave = findViewById(R.id.btn_save) - btnPickKeys.setOnClickListener { - val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply { - addCategory(Intent.CATEGORY_OPENABLE) - type = "*/*" - } - startActivityForResult(intent, REQ_PICK_KEYS) - } + loadSettings() - refreshKeysStatus() + btnSave.setOnClickListener { saveSettings() } } override fun onResume() { super.onResume() - refreshKeysStatus() + loadSettings() + } + + private fun loadSettings() { + val p = prefs() + hostInput.setText(p.getString("host", "127.0.0.1")) + portInput.setText(p.getInt("port", 8321).toString()) + tokenInput.setText(p.getString("token", "")) + + val token = p.getString("token", "") ?: "" + statusText.text = if (token.isEmpty()) { + "Not configured \u2014 paste the token from: jcode serve --port 8321" + } else { + "Token configured (${token.length} chars). Save and return to connect." + } } - private fun refreshKeysStatus() { - val f = File(filesDir, "jcode.json") - if (!f.exists()) { - keysStatus.text = getString(R.string.no_keys) + private fun saveSettings() { + val host = hostInput.text.toString().trim() + val portStr = portInput.text.toString().trim() + val token = tokenInput.text.toString().trim() + + if (host.isEmpty()) { + Toast.makeText(this, "Host cannot be empty", Toast.LENGTH_SHORT).show() 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}" + + val port = portStr.toIntOrNull() + if (port == null || port < 1 || port > 65535) { + Toast.makeText(this, "Port must be 1\u201365535", Toast.LENGTH_SHORT).show() + return } - } - 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() + if (token.isEmpty()) { + Toast.makeText(this, "Token cannot be empty", Toast.LENGTH_SHORT).show() + return } + + prefs().edit() + .putString("host", host) + .putInt("port", port) + .putString("token", token) + .apply() + + Toast.makeText(this, "Settings saved", Toast.LENGTH_SHORT).show() + statusText.text = "Saved. Return to main screen to connect." } companion object { private const val TAG = "jcode.Settings" - private const val REQ_PICK_KEYS = 1001 } } --- a/android/app/src/main/res/layout/activity_main.xml +++ b/android/app/src/main/res/layout/activity_main.xml @@ -59,6 +59,40 @@ android:src="@android:drawable/ic_menu_preferences" /> </LinearLayout> + <!-- Connection banner (shown when disconnected) --> + <LinearLayout + android:id="@+id/connection_banner" + android:layout_width="match_parent" + android:layout_height="wrap_content" + android:background="@color/error_bg" + android:gravity="center_vertical" + android:orientation="horizontal" + android:paddingStart="16dp" + android:paddingEnd="8dp" + android:paddingTop="8dp" + android:paddingBottom="8dp" + android:visibility="gone"> + + <TextView + android:id="@+id/banner_text" + android:layout_width="0dp" + android:layout_height="wrap_content" + android:layout_weight="1" + android:text="Not connected"