M1+M2: gateway core loop + agent transparency

M1 (gateway core loop / text round-trip):
- WS server (ws_server.py): bind, hello auth (constant-time), hello.ack, heartbeat, connection registry
- pairing.py: token generation, pairing store, QR payload
- adapter.py: send() -> message frame; inbound message.send -> MessageEvent -> handle_message
- app: Connect screen, GatewayClient (connect + reconnect), ChatScreen send/render, SecureStore (Android/Desktop)
- tests/ws_probe.py: probe harness driving a real turn

M2 (streaming + reasoning + tools + commentary):
- protocol.py: M2 frame types (message.start/update/stop, tool.start/progress/end, commentary)
- adapter.py: per-chat turn-state machine; classify outbound into frames; _split_reasoning; tool-line parsing
- reasoning in streaming: capture via on_stream_delta hook (kind=reasoning, gated by plugins.stream_reasoning_deltas) with a FIFO barrier, attach to message.stop
- app: live streaming bubble, ReasoningBlock (collapse + copy), ToolCard (Everything/Truncated/Nothing), dimmed commentary, typing
- docs/14-milestones.md: M1/M2 marked done; reasoning note corrected
This commit is contained in:
ARIA committed 2026-08-19 13:56:19 +02:00
1 parent 59acf66c89
commit 218c50d688
21 files changed
+3437 -129

No files matched your search

@@ -7,6 +7,7 @@
<application
android:label="Iris"
android:allowBackup="true"
android:usesCleartextTraffic="true"
android:theme="@android:style/Theme.Material.NoActionBar">
<activity
android:name=".MainActivity"
@@ -4,12 +4,14 @@ import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import iris.IrisApp
import iris.platform.AndroidSecureStore
class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val store = AndroidSecureStore(applicationContext)
setContent {
IrisApp()
IrisApp(store)
}
}
}
+1
View File
@@ -2,6 +2,7 @@ plugins {
kotlin("multiplatform") version "2.1.0" apply false
kotlin("android") version "2.1.0" apply false
kotlin("jvm") version "2.1.0" apply false
kotlin("plugin.serialization") version "2.1.0" apply false
id("com.android.application") version "8.7.3" apply false
id("com.android.library") version "8.7.3" apply false
id("org.jetbrains.compose") version "1.7.3" apply false
@@ -3,9 +3,11 @@ package iris.desktop
import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application
import iris.IrisApp
import iris.platform.DesktopSecureStore
fun main() = application {
val store = DesktopSecureStore()
Window(onCloseRequest = ::exitApplication, title = "Iris") {
IrisApp()
IrisApp(store)
}
}
+9
View File
@@ -1,11 +1,15 @@
plugins {
kotlin("multiplatform")
kotlin("plugin.serialization")
id("com.android.library")
id("org.jetbrains.compose")
id("org.jetbrains.kotlin.plugin.compose")
}
val composeVersion = "1.7.3"
val coroutinesVersion = "1.9.0"
val serializationVersion = "1.7.3"
val okhttpVersion = "4.12.0"
kotlin {
androidTarget()
@@ -17,6 +21,11 @@ kotlin {
implementation("org.jetbrains.compose.foundation:foundation:$composeVersion")
implementation("org.jetbrains.compose.material3:material3:$composeVersion")
implementation("org.jetbrains.compose.ui:ui:$composeVersion")
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:$serializationVersion")
// OkHttp is a JVM library; both targets here are JVM-based
// (androidTarget + jvm("desktop")), so it resolves on both.
implementation("com.squareup.okhttp3:okhttp:$okhttpVersion")
}
}
}
@@ -0,0 +1,50 @@
package iris.platform
import android.content.Context
import android.os.Build
import iris.data.SecureStore
import java.util.UUID
/**
* Android pairing storage. M1: SharedPreferences (dev). M5 moves the token
* to EncryptedSharedPreferences per docs/10 §10.2.
*/
class AndroidSecureStore(context: Context) : SecureStore {
private val prefs = context.applicationContext.getSharedPreferences("iris", Context.MODE_PRIVATE)
override var serverUrl: String
get() = prefs.getString(KEY_URL, "").orEmpty()
set(value) = prefs.edit().putString(KEY_URL, value.trim()).apply()
override var token: String
get() = prefs.getString(KEY_TOKEN, "").orEmpty()
set(value) = prefs.edit().putString(KEY_TOKEN, value.trim()).apply()
override val deviceId: String
get() {
var id = prefs.getString(KEY_DEVICE_ID, null)
if (id.isNullOrBlank()) {
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
}
return id
}
override val deviceName: String
get() = "${Build.MANUFACTURER} ${Build.MODEL}".trim()
override fun savePairing(url: String, token: String) {
serverUrl = url
this.token = token
}
override fun clear() {
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
}
private companion object {
const val KEY_URL = "server_url"
const val KEY_TOKEN = "token"
const val KEY_DEVICE_ID = "device_id"
}
}
@@ -1,52 +1,59 @@
package iris
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.darkColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.state.IrisController
import iris.ui.screens.ChatScreen
import iris.ui.screens.ConnectScreen
private val IrisDark = darkColorScheme(
background = Color(0xFF1B1E28),
surface = Color(0xFF222634),
onBackground = Color(0xFFE8EAF0),
onSurface = Color(0xFFE8EAF0),
primary = Color(0xFF4F7CFF),
)
/**
* Root composable shared by the Android and Desktop shells.
*
* M0: a visible placeholder to confirm the Compose stack renders. M1+
* replaces this with the Connect screen, chat UI, and the rest of the app
* (see docs/10-android-app.md).
* M1: routes between the Connect screen (unpaired / auth failed) and the
* Chat screen (paired). Later milestones add the channel list, search,
* settings, and media (docs/10-android-app.md).
*/
@Composable
fun IrisApp() {
MaterialTheme {
Surface(modifier = Modifier.fillMaxSize()) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color(0xFF1B1E28)),
contentAlignment = Alignment.Center,
) {
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Box(
modifier = Modifier
.size(72.dp)
.background(Color(0xFF4F7CFF), CircleShape),
contentAlignment = Alignment.Center,
) {
Text(text = "I", color = Color.White, fontSize = 40.sp)
}
Spacer(modifier = Modifier.height(16.dp))
Text(text = "Iris × Hermes", color = Color.White, fontSize = 22.sp)
}
fun IrisApp(store: SecureStore) {
val controller = remember(store) { IrisController(store) }
DisposableEffect(controller) {
onDispose { controller.dispose() }
}
val state by controller.client.state.collectAsState()
MaterialTheme(colorScheme = IrisDark) {
Surface(modifier = Modifier.background(IrisDark.background)) {
val s = state
when (s) {
GatewayClient.State.Disconnected ->
ConnectScreen(controller, prefillUrl = store.serverUrl, prefillToken = store.token)
is GatewayClient.State.AuthFailed ->
ConnectScreen(
controller,
prefillUrl = store.serverUrl,
prefillToken = store.token,
initialError = "Pairing rejected: ${s.message}",
)
else -> ChatScreen(controller)
}
}
}
@@ -0,0 +1,263 @@
package iris.data
import iris.protocol.CommentaryPayload
import iris.protocol.Frame
import iris.protocol.MessagePayload
import iris.protocol.MessageStartPayload
import iris.protocol.MessageStopPayload
import iris.protocol.MessageUpdatePayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ROLE_USER
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.ToolEndPayload
import iris.protocol.ToolProgressPayload
import iris.protocol.ToolStartPayload
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.serialization.json.JsonElement
import kotlin.random.Random
/**
* In-memory chat cache (M2). The server is authoritative; this is a cache that
* reconciles on server frames. M2 adds streaming bubbles (message.start/
* update/stop), structured tool cards (tool.start/progress/end), and dimmed
* commentary beats, all interleaved in chronological order as [ChatItem]s.
* SQLDelight persistence lands with the channel/message schema later.
*/
sealed interface ChatItem {
val id: String
}
/** A chat message (user / assistant / commentary / streaming bubble). */
data class MessageItem(
override val id: String,
val role: String,
val text: String,
val ts: Long,
val pending: Boolean = false,
val reasoning: String? = null,
val isCommentary: Boolean = false,
val streaming: Boolean = false,
val model: String? = null,
val tokens: Int? = null,
) : ChatItem
/** A structured tool-activity card (spinner until [done]). */
data class ToolItem(
override val id: String,
val index: Int,
val name: String,
val preview: String? = null,
val args: JsonElement? = null,
val note: String? = null,
val done: Boolean = false,
val ok: Boolean = true,
val duration: Double? = null,
val outputPreview: String? = null,
) : ChatItem
class ChatStore {
private val _items = MutableStateFlow<List<ChatItem>>(emptyList())
val items: StateFlow<List<ChatItem>> = _items.asStateFlow()
private var localSeq = 0
private var toolSeq = 0
/** Optimistic add: show the user's message immediately (pending). */
fun addPending(text: String): String {
localSeq++
val id = "local_$localSeq"
_items.value = _items.value +
MessageItem(id = id, role = ROLE_USER, text = text, ts = 0, pending = true)
return id
}
/** Reconcile a server frame into the cache. */
fun onFrame(frame: Frame) {
when (frame.type) {
TYPE_MESSAGE -> onMessage(frame)
TYPE_MESSAGE_START -> onMessageStart(frame)
TYPE_MESSAGE_UPDATE -> onMessageUpdate(frame)
TYPE_MESSAGE_STOP -> onMessageStop(frame)
TYPE_TOOL_START -> onToolStart(frame)
TYPE_TOOL_PROGRESS -> onToolProgress(frame)
TYPE_TOOL_END -> onToolEnd(frame)
TYPE_COMMENTARY -> onCommentary(frame)
else -> Unit
}
}
// ── message (final / standalone, incl. non-streaming + user echo) ─────
private fun onMessage(frame: Frame) {
val p = frame.payloadAs<MessagePayload>() ?: return
val list = _items.value.toMutableList()
val byId = list.indexOfFirst { it.id == p.messageId }
if (byId >= 0) {
list[byId] = (list[byId] as? MessageItem)?.copy(
text = p.text,
reasoning = p.reasoning,
pending = false,
streaming = false,
model = p.model,
tokens = p.tokens,
ts = p.ts ?: 0,
) ?: return
_items.value = list
return
}
if (p.role == ROLE_USER) {
// Replace the matching optimistic pending bubble (server echo).
val pendingIdx = list.indexOfLast {
it is MessageItem && it.pending && it.role == ROLE_USER && it.text == p.text
}
if (pendingIdx >= 0) {
list[pendingIdx] = MessageItem(
id = p.messageId, role = p.role, text = p.text,
ts = p.ts ?: 0, reasoning = p.reasoning,
)
_items.value = list
return
}
}
list.add(
MessageItem(
id = p.messageId, role = p.role, text = p.text, ts = p.ts ?: 0,
reasoning = p.reasoning, model = p.model, tokens = p.tokens,
),
)
_items.value = list
}
// ── message.start (open a live streaming bubble) ──────────────────────
private fun onMessageStart(frame: Frame) {
val p = frame.payloadAs<MessageStartPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
id = p.messageId, role = p.role, text = "",
ts = 0, streaming = true,
),
)
_items.value = list
}
// ── message.update (replace live bubble text; full snapshot) ──────────
private fun onMessageUpdate(frame: Frame) {
val p = frame.payloadAs<MessageUpdatePayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) return
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(text = p.text)
_items.value = list
}
// ── message.stop (finalize the live bubble) ───────────────────────────
private fun onMessageStop(frame: Frame) {
val p = frame.payloadAs<MessageStopPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfFirst { it.id == p.messageId }
if (idx < 0) {
// No live bubble (e.g. missed start) — materialize a final one.
list.add(
MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.finalText,
ts = p.ts ?: 0, reasoning = p.reasoning,
model = p.model, tokens = p.tokens,
),
)
_items.value = list
return
}
val cur = list[idx] as? MessageItem ?: return
list[idx] = cur.copy(
text = p.finalText,
reasoning = p.reasoning,
model = p.model,
tokens = p.tokens,
streaming = false,
ts = p.ts ?: cur.ts,
)
_items.value = list
}
// ── tool.start (new tool card) ────────────────────────────────────────
private fun onToolStart(frame: Frame) {
val p = frame.payloadAs<ToolStartPayload>() ?: return
toolSeq++
val id = "tool_$toolSeq"
val list = _items.value.toMutableList()
list.add(
ToolItem(
id = id, index = p.index, name = p.name,
preview = p.preview, args = p.args,
),
)
_items.value = list
}
// ── tool.progress (in-progress note) ──────────────────────────────────
private fun onToolProgress(frame: Frame) {
val p = frame.payloadAs<ToolProgressPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(note = p.note)
_items.value = list
}
// ── tool.end (mark the tool card complete) ────────────────────────────
private fun onToolEnd(frame: Frame) {
val p = frame.payloadAs<ToolEndPayload>() ?: return
val list = _items.value.toMutableList()
val idx = list.indexOfLast { it is ToolItem && !it.done && it.index == p.index }
if (idx < 0) return
val cur = list[idx] as ToolItem
list[idx] = cur.copy(
done = true, ok = p.ok, duration = p.duration,
outputPreview = p.outputPreview,
)
_items.value = list
}
// ── commentary (dimmed interim beat) ──────────────────────────────────
private fun onCommentary(frame: Frame) {
val p = frame.payloadAs<CommentaryPayload>() ?: return
val list = _items.value.toMutableList()
if (list.any { it.id == p.messageId }) return
list.add(
MessageItem(
id = p.messageId, role = ROLE_ASSISTANT, text = p.text,
ts = 0, isCommentary = true,
),
)
_items.value = list
}
fun clear() {
_items.value = emptyList()
}
companion object {
fun randomId(prefix: String): String =
"${prefix}${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
}
}
@@ -0,0 +1,23 @@
package iris.data
/**
* Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain
* SharedPreferences for M1 dev, file on desktop).
*/
interface SecureStore {
/** ws(s)://host:port/ws */
var serverUrl: String
/** ANDROID_TOKEN presented in the hello frame. */
var token: String
/** Stable app-generated device id (persisted). */
val deviceId: String
/** Human-readable device name (e.g. "MIX 2S"). */
val deviceName: String
fun savePairing(url: String, token: String)
fun clear()
}
@@ -0,0 +1,281 @@
package iris.net
import iris.data.SecureStore
import iris.protocol.ChannelInfo
import iris.protocol.ErrorPayload
import iris.protocol.Frame
import iris.protocol.HelloAckPayload
import iris.protocol.IrisJson
import iris.protocol.ServerCaps
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HELLO_ACK
import iris.protocol.TYPE_PONG
import iris.protocol.helloFrame
import iris.protocol.messageSendFrame
import iris.protocol.pingFrame
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.withTimeoutOrNull
import kotlin.time.TimeMark
import kotlin.time.TimeSource
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit
import kotlin.random.Random
/**
* OkHttp WebSocket client for the hermes android gateway (docs/10 §10.3).
*
* - connect + hello (real auth leg), hello.ack
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - heartbeat: app-level ping every 20s; reap after ~60s of silence
* - events: server frames (minus hello.ack) on [events]
* - request/response correlation by id (M2+ consumers)
*/
class GatewayClient(
private val scope: CoroutineScope,
private val store: SecureStore,
) {
sealed interface State {
data object Disconnected : State
data object Connecting : State
data class Connected(val caps: ServerCaps, val channels: List<ChannelInfo>) : State
data object Reconnecting : State
data class AuthFailed(val message: String) : State
}
private val _state = MutableStateFlow<State>(State.Disconnected)
val state: StateFlow<State> = _state.asStateFlow()
private val _events = MutableSharedFlow<Frame>(extraBufferCapacity = 128)
val events: SharedFlow<Frame> = _events.asSharedFlow()
private val client: OkHttpClient = OkHttpClient.Builder()
.pingInterval(20, TimeUnit.SECONDS)
.build()
private var connectJob: Job? = null
private var socket: WebSocket? = null
private var nextRequestId = 1
private var attempt = 0
private var lastLiveness: TimeMark = TimeSource.Monotonic.markNow()
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// ── Lifecycle ─────────────────────────────────────────────────────────
/** Start (or resume) the connect loop. Idempotent. */
fun start() {
if (connectJob?.isActive == true) return
attempt = 0
connectJob = scope.launch { connectLoop() }
}
/** Stop the connect loop and close the socket. */
fun stop() {
connectJob?.cancel()
connectJob = null
socket?.close(1000, "client shutdown")
socket = null
_state.value = State.Disconnected
}
/** Re-pair: stop, then start fresh (used after saving new settings). */
fun restart() {
stop()
start()
}
private suspend fun connectLoop() {
while (currentCoroutineContext().isActive) {
val url = store.serverUrl.trim()
val token = store.token
if (url.isBlank() || token.isBlank()) {
_state.value = State.Disconnected
return
}
_state.value = if (attempt == 0) State.Connecting else State.Reconnecting
val dial = dial(url, token)
when (val result = dial.result) {
is DialResult.AuthFailed -> {
_state.value = State.AuthFailed(result.message)
dial.socket.close(1000, "auth failed")
return
}
DialResult.Connected -> {
attempt = 0
lastLiveness = TimeSource.Monotonic.markNow()
dial.closed.await()
if (!currentCoroutineContext().isActive) return
// socket dropped -> loop again (Reconnecting)
}
is DialResult.Failed -> {
attempt++
delay(backoffMs(attempt))
}
}
}
}
// ── Dial (one connect + hello) ────────────────────────────────────────
private sealed interface DialResult {
data object Connected : DialResult
data class AuthFailed(val message: String) : DialResult
data class Failed(val message: String) : DialResult
}
private data class Dial(
val result: DialResult,
val socket: WebSocket,
val closed: CompletableDeferred<Unit>,
)
private suspend fun dial(url: String, token: String): Dial {
val closed = CompletableDeferred<Unit>()
val helloAck = CompletableDeferred<HelloAckPayload>()
val authError = CompletableDeferred<String>()
val fail = CompletableDeferred<String>()
val request = Request.Builder().url(url).build()
val ws = client.newWebSocket(
request,
object : WebSocketListener() {
override fun onOpen(webSocket: WebSocket, response: Response) {
webSocket.send(helloFrame(token, store.deviceId, store.deviceName).toWire())
}
override fun onMessage(webSocket: WebSocket, text: String) {
lastLiveness = TimeSource.Monotonic.markNow()
val frame = try {
IrisJson.instance.decodeFromString(Frame.serializer(), text)
} catch (_: Exception) {
return
}
when (frame.type) {
TYPE_HELLO_ACK -> {
val ack = frame.payloadAs<HelloAckPayload>()
if (ack != null) helloAck.complete(ack)
}
TYPE_ERROR -> {
val err = frame.payloadAs<ErrorPayload>()
authError.complete(err?.message ?: "auth failed")
}
TYPE_PONG -> Unit
else -> {
_events.tryEmit(frame)
frame.id?.let { pending[it]?.complete(frame) }
}
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closed.complete(Unit)
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
fail.complete(t.message ?: "connection failed")
closed.complete(Unit)
}
},
)
socket = ws
val winner = CompletableDeferred<DialResult>()
helloAck.invokeOnCompletion { e ->
if (e == null) {
val ack = helloAck.getCompleted()
_state.value = State.Connected(ack.serverCaps, ack.channels)
winner.complete(DialResult.Connected)
}
}
authError.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.AuthFailed(authError.getCompleted()))
}
fail.invokeOnCompletion { e ->
if (e == null) winner.complete(DialResult.Failed(fail.getCompleted()))
}
val result = withTimeoutOrNull(15_000) { winner.await() }
?: DialResult.Failed("timeout waiting for hello.ack")
return Dial(result, ws, closed)
}
// ── Outbound ──────────────────────────────────────────────────────────
/** Send a text message (fire-and-forget; the server echoes it back). */
fun sendMessage(chatId: String, text: String, threadId: String? = null) {
val ws = socket ?: return
val id = nextRequestId++
ws.send(messageSendFrame(id, chatId, text, threadId).toWire())
}
/** Send a ping (heartbeat). */
fun ping() {
socket?.send(pingFrame().toWire())
}
/** True when the socket has been silent for [timeoutMs] (heartbeat reap). */
fun isStale(timeoutMs: Long = 60_000): Boolean =
_state.value is State.Connected && lastLiveness.elapsedNow().inWholeMilliseconds > timeoutMs
fun reapStale() {
if (isStale()) {
socket?.close(1000, "heartbeat timeout")
}
}
// ── One-shot hello test (Connect screen) ──────────────────────────────
/**
* Real `hello` test: dial, wait for hello.ack (or auth error), close.
* Exercises the auth leg, not just TCP (docs/10 §10.8).
*/
suspend fun testHello(url: String, token: String): Result<Unit> {
val dial = dial(url, token)
return when (val result = dial.result) {
DialResult.Connected -> {
dial.socket.close(1000, "test complete")
Result.success(Unit)
}
is DialResult.AuthFailed -> Result.failure(IllegalStateException(result.message))
is DialResult.Failed -> Result.failure(IllegalStateException(result.message))
}
}
// ── Heartbeat job ─────────────────────────────────────────────────────
fun startHeartbeat() {
scope.launch {
while (isActive) {
delay(20_000)
if (_state.value is State.Connected) {
ping()
reapStale()
}
}
}
}
// ── Helpers ───────────────────────────────────────────────────────────
private fun backoffMs(attempt: Int): Long {
val base = 1_000L * (1L shl minOf(attempt, 5)) // 1s..32s
val capped = minOf(base, 30_000L)
return capped + Random.nextLong(0, 500)
}
}
private fun Frame.toWire(): String = IrisJson.instance.encodeToString(Frame.serializer(), this)
@@ -0,0 +1,238 @@
package iris.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
/**
* Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send,
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/
* progress/end, commentary, reasoning (on message / message.stop).
*/
const val PROTOCOL_VERSION = 1
object IrisJson {
val instance: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = true
isLenient = true
}
}
// ── Frame type constants ────────────────────────────────────────────────
const val TYPE_HELLO = "hello"
const val TYPE_HELLO_ACK = "hello.ack"
const val TYPE_MESSAGE = "message"
const val TYPE_MESSAGE_SEND = "message.send"
const val TYPE_ERROR = "error"
const val TYPE_PING = "ping"
const val TYPE_PONG = "pong"
const val TYPE_TYPING = "typing"
// M2 — streaming / tools / commentary
const val TYPE_MESSAGE_START = "message.start"
const val TYPE_MESSAGE_UPDATE = "message.update"
const val TYPE_MESSAGE_STOP = "message.stop"
const val TYPE_TOOL_START = "tool.start"
const val TYPE_TOOL_PROGRESS = "tool.progress"
const val TYPE_TOOL_END = "tool.end"
const val TYPE_COMMENTARY = "commentary"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
// ── Roles ───────────────────────────────────────────────────────────────
const val ROLE_USER = "user"
const val ROLE_ASSISTANT = "assistant"
// ── Envelope ────────────────────────────────────────────────────────────
@Serializable
data class Frame(
val v: Int = PROTOCOL_VERSION,
val id: Int? = null,
val type: String,
@SerialName("chat_id") val chatId: String? = null,
@SerialName("thread_id") val threadId: String? = null,
val payload: JsonElement = JsonObject(emptyMap()),
) {
/** Payload as a JSON object (the wire format); parse per-type with
* [IrisJson]. Unknown fields are ignored (forward-compat). */
inline fun <reified T : Any> payloadAs(): T? =
try {
IrisJson.instance.decodeFromString<T>(payload.toString())
} catch (_: Exception) {
null
}
}
// ── hello (app -> server) ───────────────────────────────────────────────
@Serializable
data class HelloPayload(
val token: String,
@SerialName("device_id") val deviceId: String,
@SerialName("device_name") val deviceName: String,
val caps: JsonElement = buildJsonObject { put("min_protocol", JsonPrimitive(1)) },
@SerialName("fcm_token") val fcmToken: String? = null,
@SerialName("ntfy_topic") val ntfyTopic: String? = null,
)
// ── hello.ack (server -> app) ───────────────────────────────────────────
@Serializable
data class ServerCaps(
val streaming: Boolean = false,
val reasoning: Boolean = false,
val tools: Boolean = false,
val media: Boolean = false,
val search: Boolean = false,
val push: String = "fcm",
val pickers: Boolean = false,
)
@Serializable
data class ChannelInfo(
@SerialName("chat_id") val chatId: String,
val name: String,
val kind: String = "channel",
@SerialName("is_default") val isDefault: Boolean = false,
)
@Serializable
data class HelloAckPayload(
@SerialName("server_caps") val serverCaps: ServerCaps = ServerCaps(),
@SerialName("sync_cursor") val syncCursor: Long = 0,
val channels: List<ChannelInfo> = emptyList(),
)
// ── message (server -> app) ─────────────────────────────────────────────
@Serializable
data class MessagePayload(
@SerialName("message_id") val messageId: String,
val role: String,
val text: String,
val reasoning: String? = null,
@SerialName("reply_to") val replyTo: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
)
// ── M2: streaming frames (server -> app) ────────────────────────────────
@Serializable
data class MessageStartPayload(
@SerialName("message_id") val messageId: String,
val role: String = ROLE_ASSISTANT,
)
@Serializable
data class MessageUpdatePayload(
@SerialName("message_id") val messageId: String,
val text: String,
)
@Serializable
data class MessageStopPayload(
@SerialName("message_id") val messageId: String,
@SerialName("final_text") val finalText: String,
val reasoning: String? = null,
val model: String? = null,
val tokens: Int? = null,
val ts: Long? = null,
)
// ── M2: tool activity frames (server -> app) ────────────────────────────
@Serializable
data class ToolStartPayload(
val index: Int,
val name: String,
val preview: String? = null,
val args: JsonElement? = null,
)
@Serializable
data class ToolProgressPayload(
val index: Int,
val name: String,
val note: String? = null,
)
@Serializable
data class ToolEndPayload(
val index: Int,
val name: String,
val ok: Boolean = true,
val duration: Double? = null,
@SerialName("output_preview") val outputPreview: String? = null,
)
// ── M2: commentary frame (server -> app) ────────────────────────────────
@Serializable
data class CommentaryPayload(
@SerialName("message_id") val messageId: String,
val text: String,
)
// ── message.send (app -> server) ────────────────────────────────────────
@Serializable
data class MessageSendPayload(
val text: String,
@SerialName("reply_to") val replyTo: String? = null,
@SerialName("media_refs") val mediaRefs: List<String> = emptyList(),
)
// ── typing / error / ping ───────────────────────────────────────────────
@Serializable
data class TypingPayload(val on: Boolean)
@Serializable
data class ErrorPayload(val code: String, val message: String)
@Serializable
data class PingPayload(val ts: Long? = null)
// ── Frame builders ──────────────────────────────────────────────────────
fun helloFrame(token: String, deviceId: String, deviceName: String): Frame =
Frame(
type = TYPE_HELLO,
payload = IrisJson.instance.encodeToJsonElement(
HelloPayload.serializer(),
HelloPayload(token = token, deviceId = deviceId, deviceName = deviceName),
),
)
fun messageSendFrame(id: Int, chatId: String, text: String, threadId: String? = null): Frame =
Frame(
id = id,
type = TYPE_MESSAGE_SEND,
chatId = chatId,
threadId = threadId,
payload = IrisJson.instance.encodeToJsonElement(
MessageSendPayload.serializer(),
MessageSendPayload(text = text),
),
)
fun pingFrame(): Frame =
Frame(type = TYPE_PING, payload = IrisJson.instance.encodeToJsonElement(PingPayload.serializer(), PingPayload()))
@@ -0,0 +1,122 @@
package iris.state
import iris.data.ChatStore
import iris.data.SecureStore
import iris.net.GatewayClient
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_TOOL_END
import iris.protocol.TYPE_TOOL_PROGRESS
import iris.protocol.TYPE_TOOL_START
import iris.protocol.TYPE_TYPING
import iris.protocol.TypingPayload
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
/**
* App-level controller (M2): owns the GatewayClient + ChatStore, routes
* server frames (message / streaming / tool / commentary / typing) into
* state, and exposes the actions the UI needs.
*/
class IrisController(
private val store: SecureStore,
) {
private val job = SupervisorJob()
private val scope = CoroutineScope(job + Dispatchers.Default)
val client = GatewayClient(scope, store)
val chat = ChatStore()
private val _typing = MutableStateFlow(false)
val typing: StateFlow<Boolean> = _typing.asStateFlow()
/** Tool-card verbosity (M2). NOTHING suppresses tool cards entirely. */
private val _toolDetail = MutableStateFlow(ToolDetail.TRUNCATED)
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() {
_toolDetail.value = when (_toolDetail.value) {
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
}
}
/** Home channel id (from hello.ack; default until then). */
private val _homeChannel = MutableStateFlow("android:default")
val homeChannel: StateFlow<String> = _homeChannel.asStateFlow()
init {
scope.launch {
client.events.collect { frame ->
when (frame.type) {
TYPE_MESSAGE,
TYPE_MESSAGE_START,
TYPE_MESSAGE_UPDATE,
TYPE_MESSAGE_STOP,
TYPE_TOOL_START,
TYPE_TOOL_PROGRESS,
TYPE_TOOL_END,
TYPE_COMMENTARY -> chat.onFrame(frame)
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
else -> Unit
}
}
}
scope.launch {
client.state.collect { s ->
if (s is GatewayClient.State.Connected) {
val home = s.channels.firstOrNull { it.isDefault }?.chatId
if (home != null) _homeChannel.value = home
}
}
}
client.startHeartbeat()
client.start()
}
/** Optimistic send: show immediately, then hand to the gateway. */
fun send(text: String) {
val trimmed = text.trim()
if (trimmed.isEmpty()) return
chat.addPending(trimmed)
client.sendMessage(_homeChannel.value, trimmed)
}
/** Connect-screen action: real hello test, then save + (re)connect. */
suspend fun connect(url: String, token: String): Result<Unit> {
val test = client.testHello(url, token)
if (test.isFailure) return test
store.savePairing(url, token)
client.restart()
return Result.success(Unit)
}
fun forget() {
store.clear()
chat.clear()
client.restart()
}
fun dispose() {
client.stop()
job.cancel()
}
}
/** How much tool detail to show (Settings → "Tool detail"). */
enum class ToolDetail {
EVERYTHING, // name + full args (collapsible) + output preview
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
NOTHING, // suppress tool cards entirely
}
@@ -0,0 +1,385 @@
package iris.ui.screens
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.ChatItem
import iris.data.MessageItem
import iris.data.ToolItem
import iris.net.GatewayClient
import iris.protocol.ROLE_USER
import iris.state.IrisController
import iris.state.ToolDetail
/**
* Chat screen (M2): message list + composer. User bubbles right (accent),
* assistant bubbles left (surface). M2 adds live streaming bubbles, a
* collapsible reasoning block above the answer, structured tool cards
* (verbosity-controlled), and dimmed commentary beats.
*/
@Composable
fun ChatScreen(controller: IrisController) {
val state by controller.client.state.collectAsState()
val items by controller.chat.items.collectAsState()
val typing by controller.typing.collectAsState()
val toolDetail by controller.toolDetail.collectAsState()
val listState = rememberLazyListState()
var input by remember { mutableStateOf("") }
fun doSend() {
if (input.isBlank()) return
val text = input
input = ""
controller.send(text)
}
LaunchedEffect(items.size, typing) {
val last = items.size - 1
if (last >= 0) listState.animateScrollToItem(last)
}
Column(modifier = Modifier.fillMaxSize()) {
// Header
Row(
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 10.dp),
verticalAlignment = Alignment.CenterVertically,
) {
Text("Iris", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold)
Spacer(modifier = Modifier.width(10.dp))
StatusChip(state)
Spacer(modifier = Modifier.weight(1f))
// M2: cycle tool-card verbosity (full Settings screen lands in M7).
TextButton(onClick = { controller.cycleToolDetail() }) {
Text("tools: ${toolDetail.label}", fontSize = 11.sp)
}
}
// Messages
LazyColumn(
state = listState,
modifier = Modifier
.weight(1f)
.fillMaxWidth(),
contentPadding = PaddingValues(16.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
if (items.isEmpty() && !typing) {
item(key = "empty") {
Box(modifier = Modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
Text(
"Say hello to your agent.",
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
items(items, key = { it.id }) { item ->
when (item) {
is MessageItem -> MessageBubble(item)
is ToolItem -> if (toolDetail != ToolDetail.NOTHING) ToolCard(item, toolDetail)
}
}
if (typing) {
item(key = "typing") {
Text(
"typing…",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(start = 12.dp),
)
}
}
}
// Composer
Row(
modifier = Modifier
.fillMaxWidth()
.padding(12.dp),
verticalAlignment = Alignment.Bottom,
) {
OutlinedTextField(
value = input,
onValueChange = { input = it },
modifier = Modifier
.weight(1f)
.heightIn(min = 48.dp, max = 160.dp),
placeholder = { Text("Message") },
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Send),
keyboardActions = KeyboardActions(onSend = { doSend() }),
)
Spacer(modifier = Modifier.width(8.dp))
Button(
onClick = { doSend() },
enabled = input.isNotBlank(),
) {
Text("Send")
}
}
}
}
@Composable
private fun StatusChip(state: GatewayClient.State) {
val (label, color) = when (state) {
GatewayClient.State.Disconnected -> "offline" to Color(0xFF9E9E9E)
GatewayClient.State.Connecting -> "connecting…" to Color(0xFFFFC107)
GatewayClient.State.Reconnecting -> "reconnecting…" to Color(0xFFFFC107)
is GatewayClient.State.Connected -> "connected" to Color(0xFF4CAF50)
is GatewayClient.State.AuthFailed -> "auth failed" to Color(0xFFF44336)
}
Box(
modifier = Modifier
.clip(RoundedCornerShape(10.dp))
.background(color.copy(alpha = 0.15f))
.padding(horizontal = 8.dp, vertical = 3.dp),
) {
Text(label, fontSize = 11.sp, color = color)
}
}
@Composable
private fun MessageBubble(msg: MessageItem) {
val isUser = msg.role == ROLE_USER
val isCommentary = msg.isCommentary
val bubbleColor = when {
isUser -> Color(0xFF4F7CFF)
isCommentary -> Color(0xFF23262F)
else -> Color(0xFF2A2E3B)
}
val textColor = when {
isUser -> Color.White
isCommentary -> Color(0xFFE8EAF0).copy(alpha = 0.55f)
else -> Color(0xFFE8EAF0)
}
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = if (isUser) Arrangement.End else Arrangement.Start,
) {
Column(
modifier = Modifier
.clip(RoundedCornerShape(14.dp))
.background(bubbleColor)
.padding(horizontal = 12.dp, vertical = 8.dp),
) {
// Reasoning block above the answer (assistant, non-commentary).
if (!isUser && !isCommentary && !msg.reasoning.isNullOrBlank()) {
ReasoningBlock(msg.reasoning!!)
Spacer(modifier = Modifier.height(6.dp))
}
if (msg.text.isNotBlank() || msg.streaming) {
Text(
msg.text + if (msg.streaming) " ▉" else "",
color = textColor,
fontSize = if (isCommentary) 13.sp else 15.sp,
)
}
if (msg.pending) {
Text("sending…", color = textColor.copy(alpha = 0.6f), fontSize = 10.sp)
}
// Model / token footer (final assistant answers only).
if (!isUser && !isCommentary && !msg.streaming &&
(msg.model != null || msg.tokens != null)
) {
Spacer(modifier = Modifier.height(4.dp))
Text(
buildString {
msg.model?.let { append(it) }
if (msg.model != null && msg.tokens != null) append(" · ")
msg.tokens?.let { append("${it} tok") }
},
color = textColor.copy(alpha = 0.4f),
fontSize = 10.sp,
)
}
}
}
}
/**
* Collapsible reasoning panel (M2): header "💭 Reasoning", monospace body,
* copy button. Collapsed by default when long; tap to toggle.
*/
@Composable
private fun ReasoningBlock(reasoning: String) {
val clipboard = LocalClipboardManager.current
var expanded by remember {
mutableStateOf(reasoning.length <= 240)
}
var copied by remember { mutableStateOf(false) }
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(8.dp))
.background(Color(0xFF1E212B))
.padding(8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Text(
"💭 Reasoning",
fontSize = 12.sp,
fontWeight = FontWeight.SemiBold,
color = Color(0xFFB9C0D0),
modifier = Modifier
.weight(1f)
.clickable { expanded = !expanded },
)
TextButton(onClick = {
clipboard.setText(AnnotatedString(reasoning))
copied = true
}) {
Text(if (copied) "copied" else "copy", fontSize = 11.sp)
}
Text(
if (expanded) "▾" else "▸",
fontSize = 12.sp,
color = Color(0xFFB9C0D0),
modifier = Modifier.clickable { expanded = !expanded },
)
}
if (expanded) {
Text(
reasoning,
fontFamily = FontFamily.Monospace,
fontSize = 12.sp,
color = Color(0xFF9AA3B5),
)
}
}
}
/**
* Structured tool-activity card (M2). Spinner while [ToolItem.done] is false,
* ✓/✗ + duration on completion. Verbosity controlled by [detail].
*/
@Composable
private fun ToolCard(tool: ToolItem, detail: ToolDetail) {
var expanded by remember { mutableStateOf(false) }
val status = when {
!tool.done -> null
tool.ok -> "✓"
else -> "✗"
}
val statusColor = when {
tool.ok -> Color(0xFF4CAF50)
else -> Color(0xFFF44336)
}
val preview = tool.preview ?: tool.note
Column(
modifier = Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
.padding(horizontal = 10.dp, vertical = 8.dp),
) {
Row(verticalAlignment = Alignment.CenterVertically) {
if (!tool.done) {
CircularProgressIndicator(
modifier = Modifier.size(14.dp),
strokeWidth = 2.dp,
)
Spacer(modifier = Modifier.width(8.dp))
} else {
Text(status!!, color = statusColor, fontSize = 14.sp)
Spacer(modifier = Modifier.width(6.dp))
}
Text(
"🔧 ${tool.name}",
fontSize = 13.sp,
fontWeight = FontWeight.Medium,
color = Color(0xFFD7DBE5),
modifier = Modifier
.weight(1f)
.clickable { expanded = !expanded },
)
if (tool.duration != null) {
Text(
"${String.format("%.1f", tool.duration)}s",
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
}
// One-liner preview (Truncated default / Everything).
if (preview != null && detail != ToolDetail.NOTHING) {
Text(
preview,
fontSize = 12.sp,
color = Color(0xFF9AA3B5),
maxLines = if (expanded) Int.MAX_VALUE else 1,
)
}
// Full args + output preview (Everything, expanded).
if (detail == ToolDetail.EVERYTHING && expanded) {
tool.args?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
it.toString(),
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
tool.outputPreview?.let {
Spacer(modifier = Modifier.height(4.dp))
Text(
it,
fontFamily = FontFamily.Monospace,
fontSize = 11.sp,
color = Color(0xFF8A93A6),
)
}
}
}
}
private val ToolDetail.label: String
get() = when (this) {
ToolDetail.EVERYTHING -> "everything"
ToolDetail.TRUNCATED -> "truncated"
ToolDetail.NOTHING -> "nothing"
}
@@ -0,0 +1,124 @@
package iris.ui.screens
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.Button
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.OutlinedButton
import androidx.compose.material3.OutlinedTextField
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.text.input.PasswordVisualTransformation
import androidx.compose.ui.unit.dp
import iris.state.IrisController
import kotlinx.coroutines.launch
/**
* Onboarding / pairing screen (docs/10 §10.8). "Test & Connect" performs a
* real `hello` (the auth leg), not just a TCP probe.
*/
@Composable
fun ConnectScreen(
controller: IrisController,
prefillUrl: String = "",
prefillToken: String = "",
initialError: String? = null,
) {
val scope = rememberCoroutineScope()
var url by remember { mutableStateOf(prefillUrl.ifBlank { "ws://" }) }
var token by remember { mutableStateOf(prefillToken) }
var busy by remember { mutableStateOf(false) }
var error by remember { mutableStateOf(initialError) }
Column(
modifier = Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Spacer(modifier = Modifier.height(48.dp))
Text("Iris × Hermes", style = MaterialTheme.typography.headlineMedium)
Spacer(modifier = Modifier.height(8.dp))
Text(
"Pair this device with your hermes gateway",
style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
Spacer(modifier = Modifier.height(32.dp))
OutlinedTextField(
value = url,
onValueChange = { url = it },
label = { Text("Server URL") },
placeholder = { Text("ws://192.168.1.10:8790/ws") },
singleLine = true,
keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(12.dp))
OutlinedTextField(
value = token,
onValueChange = { token = it },
label = { Text("Pairing token") },
placeholder = { Text("ANDROID_TOKEN (64 hex)") },
singleLine = true,
visualTransformation = PasswordVisualTransformation(),
modifier = Modifier.fillMaxWidth(),
)
Spacer(modifier = Modifier.height(24.dp))
Button(
onClick = {
if (busy) return@Button
busy = true
error = null
scope.launch {
val result = controller.connect(url.trim(), token.trim())
busy = false
if (result.isFailure) {
error = result.exceptionOrNull()?.message ?: "connection failed"
}
}
},
enabled = !busy,
modifier = Modifier.fillMaxWidth(),
) {
Text(if (busy) "Testing connection…" else "Test & Connect")
}
if (error != null) {
Spacer(modifier = Modifier.height(16.dp))
Text(
error!!,
color = MaterialTheme.colorScheme.error,
style = MaterialTheme.typography.bodyMedium,
)
}
Spacer(modifier = Modifier.height(24.dp))
Text(
"Find the token in ~/.hermes/.env (ANDROID_TOKEN) or run\n" +
"hermes gateway setup on the gateway host.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
@@ -0,0 +1,75 @@
package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import java.io.File
import java.util.UUID
import kotlinx.serialization.Serializable
/**
* Desktop pairing storage: a small JSON file under the user home
* (`~/.iris/pairing.json`). M1 dev storage; a keyring backend is a
* later-milestone concern.
*/
@Serializable
private data class PairingData(
val serverUrl: String = "",
val token: String = "",
val deviceId: String = "",
)
class DesktopSecureStore : SecureStore {
private val file: File = File(System.getProperty("user.home"), ".iris/pairing.json")
private fun load(): PairingData =
if (file.exists()) {
try {
IrisJson.instance.decodeFromString(PairingData.serializer(), file.readText())
} catch (_: Exception) {
PairingData()
}
} else {
PairingData()
}
private fun save(data: PairingData) {
file.parentFile?.mkdirs()
file.writeText(IrisJson.instance.encodeToString(PairingData.serializer(), data))
}
override var serverUrl: String
get() = load().serverUrl
set(value) {
val d = load()
save(d.copy(serverUrl = value.trim()))
}
override var token: String
get() = load().token
set(value) {
val d = load()
save(d.copy(token = value.trim()))
}
override val deviceId: String
get() {
val d = load()
if (d.deviceId.isNotBlank()) return d.deviceId
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
save(d.copy(deviceId = id))
return id
}
override val deviceName: String
get() = "Desktop (${System.getProperty("os.name")})"
override fun savePairing(url: String, token: String) {
val d = load()
save(d.copy(serverUrl = url.trim(), token = token.trim()))
}
override fun clear() {
val d = load()
save(d.copy(serverUrl = "", token = ""))
}
}