feat: add android thin client MVP

This commit is contained in:
ejclaw
2026-05-25 03:55:23 +09:00
parent 7fbcea30c4
commit 18a8892107
25 changed files with 947 additions and 0 deletions

View File

@@ -0,0 +1,25 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application
android:allowBackup="false"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:supportsRtl="true"
android:theme="@style/AppTheme"
android:usesCleartextTraffic="false">
<!-- Fill when Meta DAT Developer Preview app registration is available. -->
<meta-data
android:name="com.meta.wearable.mwdat.ANALYTICS_OPT_OUT"
android:value="true" />
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@@ -0,0 +1,166 @@
package com.ejclaw.android
import android.app.Activity
import android.os.Bundle
import android.widget.Toast
import com.ejclaw.android.api.EJClawApi
import com.ejclaw.android.display.DisplayBridge
import com.ejclaw.android.model.RoomActivity
import com.ejclaw.android.model.RoomSummary
import com.ejclaw.android.ui.EJClawView
import java.util.concurrent.Executors
class MainActivity : Activity() {
private val executor = Executors.newSingleThreadExecutor()
private val displayBridge = DisplayBridge()
private lateinit var view: EJClawView
private var api: EJClawApi? = null
private var selectedRoom: RoomSummary? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
view = EJClawView(this)
setContentView(view.root)
loadPrefs()
bindActions()
}
override fun onDestroy() {
executor.shutdownNow()
super.onDestroy()
}
private fun bindActions() {
view.connectButton.setOnClickListener { connectAndLoadRooms() }
view.refreshButton.setOnClickListener { loadRooms() }
view.sendButton.setOnClickListener { sendMessage() }
view.roomsList.setOnItemClickListener { _, _, position, _ ->
selectedRoom = view.roomsAdapter.getItem(position)
selectedRoom?.let { loadRoom(it) }
}
}
private fun loadPrefs() {
val prefs = getPreferences(MODE_PRIVATE)
view.baseUrlInput.setText(prefs.getString("baseUrl", "http://100.101.210.95:8734"))
view.tokenInput.setText(prefs.getString("token", ""))
view.nicknameInput.setText(prefs.getString("nickname", "android"))
}
private fun savePrefs() {
getPreferences(MODE_PRIVATE).edit()
.putString("baseUrl", view.baseUrlInput.text.toString().trim())
.putString("token", view.tokenInput.text.toString())
.putString("nickname", view.nicknameInput.text.toString().trim())
.apply()
}
private fun connectAndLoadRooms() {
savePrefs()
api = EJClawApi(
view.baseUrlInput.text.toString(),
view.tokenInput.text.toString(),
)
runNetwork("Connecting") {
requireNotNull(api).health()
requireNotNull(api).rooms()
}.onSuccess { rooms ->
view.statusView.text = "Connected: ${rooms.size} rooms"
replaceRooms(rooms)
}
}
private fun loadRooms() {
runNetwork("Loading rooms") {
requireNotNull(api ?: createApi()).rooms()
}.onSuccess { rooms ->
view.statusView.text = "Loaded: ${rooms.size} rooms"
replaceRooms(rooms)
}
}
private fun loadRoom(room: RoomSummary) {
runNetwork("Loading ${room.name}") {
requireNotNull(api ?: createApi()).roomTimeline(room.jid)
}.onSuccess { activity ->
renderRoom(activity)
displayBridge.updateRoom(activity.summary.name, activity.summary.status, null)
displayBridge.showLatest(activity.summary.name, activity.summary.latestText)
}
}
private fun sendMessage() {
val room = selectedRoom ?: return toast("Select a room first")
val text = view.messageInput.text.toString().trim()
if (text.isBlank()) return
runNetwork("Sending") {
requireNotNull(api ?: createApi()).sendRoomMessage(
room.jid,
text,
view.nicknameInput.text.toString(),
)
requireNotNull(api).roomTimeline(room.jid)
}.onSuccess { activity ->
view.messageInput.setText("")
renderRoom(activity)
}
}
private fun createApi(): EJClawApi {
savePrefs()
val next = EJClawApi(view.baseUrlInput.text.toString(), view.tokenInput.text.toString())
api = next
return next
}
private fun replaceRooms(rooms: List<RoomSummary>) {
view.roomsAdapter.clear()
view.roomsAdapter.addAll(rooms)
view.roomsAdapter.notifyDataSetChanged()
}
private fun renderRoom(activity: RoomActivity) {
val lines = activity.messages.takeLast(40).joinToString("\n\n") { message ->
val marker = if (message.fromMe) "me" else message.senderName.ifBlank { "agent" }
"[$marker] ${message.content}"
}
view.threadView.text = lines.ifBlank { "No messages" }
view.statusView.text = "${activity.summary.name}: ${activity.summary.status}"
}
private fun <T> runNetwork(label: String, block: () -> T): PendingUi<T> {
view.statusView.text = label
val pending = PendingUi<T>()
executor.execute {
try {
val result = block()
runOnUiThread { pending.succeed(result) }
} catch (error: Throwable) {
runOnUiThread {
view.statusView.text = "Error: ${error.message}"
toast(error.message ?: "Request failed")
}
}
}
return pending
}
private fun toast(message: String) {
Toast.makeText(this, message, Toast.LENGTH_SHORT).show()
}
}
private class PendingUi<T> {
private var callback: ((T) -> Unit)? = null
private var result: T? = null
fun onSuccess(next: (T) -> Unit) {
callback = next
result?.let(next)
}
fun succeed(value: T) {
result = value
callback?.invoke(value)
}
}

View File

@@ -0,0 +1,104 @@
package com.ejclaw.android.api
import com.ejclaw.android.model.RoomActivity
import com.ejclaw.android.model.RoomMessage
import com.ejclaw.android.model.RoomSummary
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.HttpURLConnection
import java.net.URL
import java.util.UUID
import org.json.JSONObject
class EJClawApi(
baseUrl: String,
private val token: String,
) {
private val baseUrl = baseUrl.trim().trimEnd('/')
fun health(): Boolean {
val payload = request("GET", "/api/health")
return JSONObject(payload).optBoolean("ok", false)
}
fun rooms(): List<RoomSummary> {
val payload = request("GET", "/api/rooms-timeline")
val root = JSONObject(payload)
return root.keys().asSequence().mapNotNull { key ->
root.optJSONObject(key)?.let { parseRoomSummary(it) }
}.sortedBy { it.name.lowercase() }.toList()
}
fun roomTimeline(jid: String): RoomActivity {
val payload = request("GET", "/api/rooms/${encode(jid)}/timeline")
val room = JSONObject(payload)
val messages = room.optJSONArray("messages") ?: return RoomActivity(parseRoomSummary(room), emptyList())
return RoomActivity(
parseRoomSummary(room),
(0 until messages.length()).mapNotNull { index ->
messages.optJSONObject(index)?.let { message ->
RoomMessage(
senderName = message.optString("senderName", message.optString("sender", "")),
content = message.optString("content", ""),
timestamp = message.optString("timestamp", ""),
fromMe = message.optBoolean("isFromMe", false),
)
}
},
)
}
fun sendRoomMessage(jid: String, text: String, nickname: String): Boolean {
val body = JSONObject()
.put("requestId", UUID.randomUUID().toString())
.put("text", text)
.put("nickname", nickname.ifBlank { "android" })
val payload = request("POST", "/api/rooms/${encode(jid)}/messages", body.toString())
return JSONObject(payload).optBoolean("ok", false)
}
private fun parseRoomSummary(room: JSONObject): RoomSummary {
val messages = room.optJSONArray("messages")
val latest = if (messages != null && messages.length() > 0) {
messages.optJSONObject(messages.length() - 1)?.optString("content", "") ?: ""
} else {
""
}
return RoomSummary(
jid = room.optString("jid"),
name = room.optString("name", room.optString("jid")),
status = room.optString("status", "unknown"),
latestText = latest.take(120),
)
}
private fun request(method: String, path: String, body: String? = null): String {
val connection = URL("$baseUrl$path").openConnection() as HttpURLConnection
connection.requestMethod = method
connection.connectTimeout = 8_000
connection.readTimeout = 12_000
connection.setRequestProperty("Accept", "application/json")
if (token.isNotBlank()) connection.setRequestProperty("Authorization", "Bearer $token")
if (body != null) {
val bytes = body.toByteArray(Charsets.UTF_8)
connection.doOutput = true
connection.setRequestProperty("Content-Type", "application/json")
connection.setRequestProperty("Content-Length", bytes.size.toString())
connection.outputStream.use { it.write(bytes) }
}
val status = connection.responseCode
val stream = if (status in 200..299) connection.inputStream else connection.errorStream
val response = stream?.use {
BufferedReader(InputStreamReader(it, Charsets.UTF_8)).readText()
} ?: ""
connection.disconnect()
if (status !in 200..299) {
val message = response.ifBlank { "HTTP $status" }
throw IllegalStateException(message)
}
return response
}
private fun encode(value: String): String =
java.net.URLEncoder.encode(value, Charsets.UTF_8.name()).replace("+", "%20")
}

View File

@@ -0,0 +1,13 @@
package com.ejclaw.android.display
class DisplayBridge(
private val surface: DisplaySurface = NoopDisplaySurface(),
) {
fun updateRoom(roomName: String, status: String, progress: String?) {
surface.showStatus(roomName, status, progress)
}
fun showLatest(roomName: String, text: String) {
if (text.isNotBlank()) surface.showMessage(roomName, text)
}
}

View File

@@ -0,0 +1,6 @@
package com.ejclaw.android.display
interface DisplaySurface {
fun showStatus(roomName: String, state: String, progress: String?)
fun showMessage(roomName: String, text: String)
}

View File

@@ -0,0 +1,15 @@
package com.ejclaw.android.display
/**
* Meta DAT integration point.
*
* Keep this class dependency-free until the Wearables Developer Center project,
* GitHub Packages token, and DAT application id are available. The phone app can
* validate EJClaw connectivity through NoopDisplaySurface first, then this class
* can be swapped to call the Meta SDK.
*/
class MetaDatDisplaySurface : DisplaySurface {
override fun showStatus(roomName: String, state: String, progress: String?) = Unit
override fun showMessage(roomName: String, text: String) = Unit
}

View File

@@ -0,0 +1,16 @@
package com.ejclaw.android.display
class NoopDisplaySurface : DisplaySurface {
var lastStatus: String = ""
private set
var lastMessage: String = ""
private set
override fun showStatus(roomName: String, state: String, progress: String?) {
lastStatus = listOfNotNull(roomName, state, progress).joinToString(" / ")
}
override fun showMessage(roomName: String, text: String) {
lastMessage = "$roomName: $text"
}
}

View File

@@ -0,0 +1,25 @@
package com.ejclaw.android.model
data class RoomSummary(
val jid: String,
val name: String,
val status: String,
val latestText: String,
) {
override fun toString(): String {
val suffix = if (latestText.isBlank()) "" else "\n$latestText"
return "$name [$status]$suffix"
}
}
data class RoomMessage(
val senderName: String,
val content: String,
val timestamp: String,
val fromMe: Boolean,
)
data class RoomActivity(
val summary: RoomSummary,
val messages: List<RoomMessage>,
)

View File

@@ -0,0 +1,80 @@
package com.ejclaw.android.ui
import android.app.Activity
import android.graphics.Typeface
import android.text.InputType
import android.view.View
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.EditText
import android.widget.LinearLayout
import android.widget.ListView
import android.widget.ScrollView
import android.widget.TextView
import com.ejclaw.android.model.RoomSummary
class EJClawView(
activity: Activity,
) {
val baseUrlInput = EditText(activity)
val tokenInput = EditText(activity)
val nicknameInput = EditText(activity)
val connectButton = Button(activity)
val refreshButton = Button(activity)
val sendButton = Button(activity)
val messageInput = EditText(activity)
val statusView = TextView(activity)
val threadView = TextView(activity)
val roomsAdapter = ArrayAdapter<RoomSummary>(activity, android.R.layout.simple_list_item_1)
val roomsList = ListView(activity)
val root: View
init {
baseUrlInput.hint = "http://100.101.210.95:8734"
baseUrlInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_URI
tokenInput.hint = "WEB_DASHBOARD_TOKEN"
tokenInput.inputType = InputType.TYPE_CLASS_TEXT or InputType.TYPE_TEXT_VARIATION_PASSWORD
nicknameInput.hint = "nickname"
nicknameInput.inputType = InputType.TYPE_CLASS_TEXT
messageInput.hint = "message"
messageInput.minLines = 2
connectButton.text = "Connect"
refreshButton.text = "Refresh"
sendButton.text = "Send"
statusView.text = "Disconnected"
threadView.text = "Select a room"
threadView.setTypeface(Typeface.MONOSPACE)
roomsList.adapter = roomsAdapter
val controls = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
setPadding(24, 24, 24, 12)
addView(baseUrlInput)
addView(tokenInput)
addView(nicknameInput)
addView(LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
addView(connectButton, LinearLayout.LayoutParams(0, -2, 1f))
addView(refreshButton, LinearLayout.LayoutParams(0, -2, 1f))
})
addView(statusView)
}
val scroll = ScrollView(activity).apply {
addView(threadView)
}
val sendRow = LinearLayout(activity).apply {
orientation = LinearLayout.HORIZONTAL
addView(messageInput, LinearLayout.LayoutParams(0, -2, 1f))
addView(sendButton)
}
root = LinearLayout(activity).apply {
orientation = LinearLayout.VERTICAL
addView(controls)
addView(roomsList, LinearLayout.LayoutParams(-1, 0, 1f))
addView(scroll, LinearLayout.LayoutParams(-1, 0, 1f))
addView(sendRow)
}
}
}

View File

@@ -0,0 +1,3 @@
<resources>
<string name="app_name">EJClaw</string>
</resources>

View File

@@ -0,0 +1,7 @@
<resources>
<style name="AppTheme" parent="android:style/Theme.Material.Light.NoActionBar">
<item name="android:fontFamily">sans</item>
<item name="android:windowLightStatusBar">true</item>
<item name="android:colorAccent">#2563EB</item>
</style>
</resources>

View File

@@ -0,0 +1,6 @@
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<!-- Personal Tailscale dashboard endpoint used by the debug APK. -->
<domain includeSubdomains="false">100.101.210.95</domain>
</domain-config>
</network-security-config>