fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
CI / Gateway plugin tests (push) Failing after 6m35s
CI / Kotlin tests (android host + desktop) (push) Successful in 6m57s

HIGH:
- ntfy listener: replace blocking exhausted() loop with SSE read + capped
  exponential-backoff reconnect; 60s read timeout
- GatewayClient.stop(): reset HTTP leg (http, httpCursor, sseFailures,
  usingLongPoll, lastAck)
- non-atomic shared state -> synchronized/@Volatile/AtomicLong/
  CopyOnWriteArrayList

MEDIUM:
- mediaId path-traversal guard (isValidMediaId) at network/app/fs boundaries
- loadFromCache: move ts=0 pending bubbles to end, keep stored order
- attachment placeholder tracked by identity, not filename
- optimistic ChannelStore updates on favorite/icon/automation/default
- secure-store caching (desktop map, android store)
- secret passed to keyring via stdin (macOS + Linux)
- SecureStore.clear() clears deviceId/syncCursor/fcmToken/ntfy*
- random ids for system messages; SSE EOF reconnect delay
- PowerShell $ escaping; dispose() cancels job before saving flows
- wire up "Forget pairing" in Settings
- move machine-specific org.gradle.java.home to user-level gradle.properties

LOW + dead code + stale comments:
- .aac->audio/aac; locale-fixed cost/size; 3-digit hex; hour+ latency
- Backdrop.DEFAULT defined once; notification id 24-bit; channel id cap
- remove dead FileSource, unused protocol/theme/media constants, empty
  onDispose, SDK_INT<O guard, hostFromUrl
- fix stale WS/SSE, M1/M5, and milestone KDoc comments

Verified: Kotlin desktop+android host tests, 100/100 Python gateway tests,
LSP clean, installed & running on device.
This commit is contained in:
ARIA committed 2026-08-23 12:57:35 +02:00
1 parent a4e4a4ea63
commit 863ab34915
32 files changed
+717 -387

No files matched your search

+3 -3
View File
@@ -25,12 +25,12 @@
## Environment / pairing quirks ## Environment / pairing quirks
- Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. The app has **no QR scanner** — pairing is manual URL + token entry. - Pairing token: `IRIS_TOKEN` in `~/.hermes/.env`. Pairing is manual URL + token entry; on **Android** there's also a QR-scan button (camera) that fills URL + token from the gateway's pairing QR. Desktop has no camera, so it's manual entry only.
- WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP. - WS default bind is `127.0.0.1`; for a phone on the LAN set `IRIS_WS_HOST` to the gateway's LAN IP.
- `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds. - `app/local.properties` (`sdk.dir`) is git-ignored and required for Android builds.
- `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy. - `google-services.json` is optional: without it FCM is inert and ntfy is the push path. Public ntfy.sh SSE is flaky — self-host ntfy.
- JDK 17; no system Gradle — always the wrapper (`./gradlew`). - **JDK 21** is required (the desktop Markdown renderer ships Java-21 bytecode); no system Gradle — always the wrapper (`./gradlew`). The JDK-21 home is machine-specific and set per machine (NOT committed): add `org.gradle.java.home=/path/to/jdk21` to `~/.gradle/gradle.properties`, or `export JAVA_HOME=/path/to/jdk21` before running `./gradlew`.
- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works. - Desktop jpackage on Linux/JDK 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks ## Testing quirks
@@ -12,6 +12,12 @@
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature android:name="android.hardware.camera" android:required="false" />
<!-- M-3: cleartext (http://) is required because the gateway is a LAN host
addressed by IP (e.g. 192.168.x.x), not a domain. Android's
network_security_config can only scope cleartext to domain names, not
IP ranges, so a per-host allowlist isn't possible for this use case.
The token is still required for auth; traffic is only ever sent to the
user-configured gateway on the local network. -->
<application <application
android:label="Iris" android:label="Iris"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
+6 -2
View File
@@ -2,8 +2,12 @@ org.gradle.jvmargs=-Xmx4g -Dfile.encoding=UTF-8
# Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is # Desktop targets the Java 21 runtime (Markdown renderer 0.44.0 is
# Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is # Java-21 bytecode). AGP is JDK-21-compatible, so the Android build is
# unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget). # unaffected (its bytecode target stays JVM 17 via minSdk/jvmTarget).
# Point this at your local JDK 21 if the path differs. #
org.gradle.java.home=/usr/lib/jvm/java-21-openjdk # The JDK-21 home is machine-specific, so it is NOT hardcoded here (a
# committed path would break every other checkout). Set it per machine via
# one of:
# - ~/.gradle/gradle.properties -> org.gradle.java.home=/path/to/jdk21
# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
org.gradle.caching=true org.gradle.caching=true
org.gradle.configuration-cache=true org.gradle.configuration-cache=true
@@ -1,6 +1,7 @@
package iris.platform package iris.platform
import android.Manifest import android.Manifest
import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
@@ -15,7 +16,9 @@ actual fun setActiveController(controller: Any?) {
actual fun syncNtfyListener(backend: String) { actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context val context = AndroidEnv.context
val intent = Intent(context, NtfyListenerService::class.java) val intent = Intent(context, NtfyListenerService::class.java)
val store = AndroidSecureStore(context) // L-18: reuse one AndroidSecureStore instead of rebuilding it (and
// re-running EncryptedSharedPreferences.create + migration) on every call.
val store = ntfyStore(context)
if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) { if (backend == "ntfy" && store.ntfyTopic.isNotBlank()) {
ContextCompat.startForegroundService(context, intent) ContextCompat.startForegroundService(context, intent)
} else { } else {
@@ -25,6 +28,18 @@ actual fun syncNtfyListener(backend: String) {
} }
} }
private val ntfyStoreLock = Any()
@Volatile
private var cachedNtfyStore: AndroidSecureStore? = null
private fun ntfyStore(context: Context): AndroidSecureStore {
cachedNtfyStore?.let { return it }
return synchronized(ntfyStoreLock) {
cachedNtfyStore ?: AndroidSecureStore(context).also { cachedNtfyStore = it }
}
}
actual fun postSystemNotification( actual fun postSystemNotification(
chatId: String?, chatId: String?,
chatName: String?, chatName: String?,
@@ -179,10 +179,20 @@ class AndroidSecureStore(
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
prefs prefs
.edit() .edit()
.remove(KEY_URL) .remove(KEY_URL)
.remove(KEY_TOKEN) .remove(KEY_TOKEN)
.remove(KEY_DEVICE_ID)
.remove(KEY_SYNC_CURSOR)
.remove(KEY_FCM_TOKEN)
.remove(KEY_NTFY_TOPIC)
.remove(KEY_NTFY_SERVER)
.remove(KEY_PUSH_BACKEND)
.apply() .apply()
} }
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/" private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable @Composable
actual fun PlatformWebView(html: String, modifier: Modifier) { actual fun PlatformWebView(
html: String,
modifier: Modifier,
) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL) val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier) WebView(state, modifier = modifier)
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack) val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) { DisposableEffect(dispatcher) {
if (dispatcher != null) { val callback =
val callback = object : OnBackPressedCallback(true) { dispatcher?.let { d ->
override fun handleOnBackPressed() { val c =
currentOnBack.value() object : OnBackPressedCallback(true) {
} override fun handleOnBackPressed() {
currentOnBack.value()
}
}
d.addCallback(c)
c
} }
dispatcher.addCallback(callback) onDispose { callback?.remove() }
onDispose { callback.remove() }
}
onDispose { }
} }
return this return this
} }
@@ -11,9 +11,9 @@ import iris.net.GatewayClient
* *
* - [onNewToken]: persist the rotated token and push it to the server via * - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token). * `fcm.register` (so the next push targets the current token).
* - [onMessageReceived]: the data payload drives a silent sync. When the app * - [onMessageReceived]: posts a system notification from the data payload.
* is foregrounded the SSE path already delivered the frame (in-app banner), * When the app is foregrounded the SSE path already delivered the frame
* so we only post a system notification when backgrounded. * (in-app banner), so we only post a notification when backgrounded.
* *
* Inert without a Firebase project (no google-services.json): the service is * Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls * declared in the manifest but never receives messages, and the app falls
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent import android.app.PendingIntent
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
/** /**
@@ -21,15 +20,22 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT" const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000 private const val NOTIF_ID_BASE = 1_000_000
fun ensureChannel(context: Context, chatId: String, chatName: String? = null) { // L-31: notification channel ids are capped at 64 chars (Android limit) and
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return // are user-visible, so a long server-provided chatId must be truncated.
private fun channelIdFor(chatId: String): String = (CHANNEL_PREFIX + chatId).take(64)
fun ensureChannel(
context: Context,
chatId: String,
chatName: String? = null,
) {
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
val id = CHANNEL_PREFIX + chatId val id = channelIdFor(chatId)
val name = chatName ?: chatId val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) { if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel( nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT) NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
.apply { description = "Iris messages for $name" } .apply { description = "Iris messages for $name" },
) )
} }
} }
@@ -43,23 +49,28 @@ object IrisNotifications {
threadId: String?, threadId: String?,
) { ) {
ensureChannel(context, chatId, chatName) ensureChannel(context, chatId, chatName)
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff) // L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
val intent = Intent(ACTION_OPEN_CHAT).apply { // to the same notification id and clobber each other.
setPackage(context.packageName) val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP val intent =
putExtra("chat_id", chatId) Intent(ACTION_OPEN_CHAT).apply {
if (threadId != null) putExtra("thread_id", threadId) setPackage(context.packageName)
} flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
val pi = PendingIntent.getActivity( putExtra("chat_id", chatId)
context, if (threadId != null) putExtra("thread_id", threadId)
id, }
intent, val pi =
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE, PendingIntent.getActivity(
) context,
id,
intent,
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
)
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify( nm.notify(
id, id,
NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId) NotificationCompat
.Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title) .setContentTitle(title)
.setContentText(body) .setContentText(body)
@@ -11,11 +11,13 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat import androidx.core.content.ContextCompat
import iris.protocol.IrisJson import iris.protocol.IrisJson
import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
@@ -28,18 +30,31 @@ import java.util.concurrent.TimeUnit
* *
* A foreground service that subscribes to this device's ntfy topic and posts * A foreground service that subscribes to this device's ntfy topic and posts
* a system notification for each push. The structured payload rides in the * a system notification for each push. The structured payload rides in the
* `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body * `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
* is the short preview. When the app is foregrounded the WS path already * body is the short preview. When the app is foregrounded the SSE path
* delivered the frame, so the service skips posting to avoid a duplicate. * already delivered the frame, so the service skips posting to avoid a
* duplicate.
*
* The stream is reconnected with capped exponential backoff when it drops
* (EOF, network error, or a non-2xx response) — `START_STICKY` alone only
* restarts the service after process death, not after a failed read.
*/ */
class NtfyListenerService : Service() { class NtfyListenerService : Service() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null private var streamJob: Job? = null
private val client = OkHttpClient.Builder() private val client =
.readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream OkHttpClient
.build() .Builder()
// ntfy sends keep-alive comments every ~10 s; a 60 s read timeout
// detects a half-open connection instead of hanging forever.
.readTimeout(60, TimeUnit.SECONDS)
.build()
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { override fun onStartCommand(
intent: Intent?,
flags: Int,
startId: Int,
): Int {
startForeground(NOTIF_ID, foregroundNotification()) startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel() streamJob?.cancel()
streamJob = scope.launch { stream() } streamJob = scope.launch { stream() }
@@ -60,46 +75,78 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/") val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic" val url = "$server/$topic"
val request = Request.Builder() val request =
.url(url) Request
.header("Accept", "text/event-stream") .Builder()
.build() .url(url)
try { .header("Accept", "text/event-stream")
client.newCall(request).execute().use { resp -> .build()
if (!resp.isSuccessful) return var backoff = 1_000L
val body = resp.body ?: return while (true) {
val source = body.source() try {
var data: String? = null client.newCall(request).execute().use { resp ->
var title: String? = null if (!resp.isSuccessful) {
var msgBody: String? = null IrisLog.w("ntfy stream HTTP ${resp.code}")
while (!source.exhausted()) { } else {
val line = source.readUtf8Line() ?: break val body = resp.body ?: return
when { readEvents(body.source())
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
} }
} }
} catch (e: Exception) {
IrisLog.w("ntfy stream dropped: ${e.message}")
} }
} catch (_: Exception) { // Stream ended (EOF, error, or non-2xx): back off and reconnect.
// Stream dropped; the service is START_STICKY so the system delay(backoff)
// restarts it. If it keeps failing, the WS path still works. backoff = (backoff * 2).coerceAtMost(30_000L)
} }
} }
private fun handleData(dataJson: String, title: String?, msgBody: String?) { /**
val data = try { * Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
IrisJson.instance.decodeFromString<JsonObject>(dataJson) * do NOT use `source.exhausted()` here — it reads until EOF and would
} catch (_: Exception) { * block forever on a live stream.
null */
private fun readEvents(source: okio.BufferedSource) {
var data: String? = null
var title: String? = null
var msgBody: String? = null
while (true) {
val line = source.readUtf8Line() ?: break
when {
line.startsWith("X-Data:") -> {
data = line.removePrefix("X-Data:").trim()
}
line.startsWith("X-Title:") -> {
title = line.removePrefix("X-Title:").trim()
}
line.startsWith("data:") -> {
msgBody = line.removePrefix("data:").trim()
}
line.isEmpty() -> {
// Event boundary: process the accumulated message.
data?.let { handleData(it, title, msgBody) }
data = null
title = null
msgBody = null
}
}
} }
}
private fun handleData(
dataJson: String,
title: String?,
msgBody: String?,
) {
val data =
try {
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
} catch (_: Exception) {
null
}
val chatId = data?.str("chat_id") ?: "default" val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id") val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the // The short preview rides in the SSE `data:` field; fall back to the
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL, LISTENER_CHANNEL,
"Iris push listener", "Iris push listener",
NotificationManager.IMPORTANCE_MIN, NotificationManager.IMPORTANCE_MIN,
) ),
) )
} }
} }
return NotificationCompat.Builder(context, LISTENER_CHANNEL) return NotificationCompat
.Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info) .setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris") .setContentTitle("Iris")
.setContentText("Listening for messages") .setContentText("Listening for messages")
@@ -146,5 +194,4 @@ class NtfyListenerService : Service() {
} }
/** Read a string field from a JSON object (null when absent / not a string). */ /** Read a string field from a JSON object (null when absent / not a string). */
private fun JsonObject?.str(key: String): String? = private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
(this?.get(key) as? JsonPrimitive)?.content
@@ -32,9 +32,9 @@ import iris.util.PairLink
/** /**
* Root composable shared by the Android and Desktop shells. * Root composable shared by the Android and Desktop shells.
* *
* M1: routes between the Connect screen (unpaired / auth failed) and the * Routes between the Connect screen (unpaired / auth failed) and the main
* Chat screen (paired). Later milestones add the channel list, search, * app (paired), which hosts the channel list, chat, search, settings, and
* settings, and media (docs/10-android-app.md). * media (docs/10-android-app.md).
*/ */
@Composable @Composable
fun IrisApp( fun IrisApp(
@@ -71,6 +71,54 @@ class ChannelStore {
_channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId } _channels.value.filter { it.chatId != p.chatId && it.parentChatId != p.chatId }
} }
// ── Optimistic local updates (M-7) ────────────────────────────────────
//
// The gateway only broadcasts channel.created/renamed/deleted — there are
// no favorite/icon/automation/default events. So a toggle sent by THIS
// device would not update its own UI until a full channel.list re-fetch.
// These apply the change locally (optimistically); a later channel.list /
// hello.ack re-seed reconciles any divergence (e.g. a server rejection).
/** Toggle the cosmetic favorite flag locally. */
fun setFavorite(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(favorite = on) }
/** Toggle the automation flag locally. */
fun setAutomation(
chatId: String,
on: Boolean,
) = update(chatId) { it.copy(automation = on) }
/** Set the icon (base64) and/or avatar color locally; null clears a field. */
fun setIcon(
chatId: String,
icon: String?,
color: String?,
) = update(chatId) { it.copy(icon = icon, color = color) }
/** Make [chatId] the default channel locally (clearing the previous one). */
fun setDefault(chatId: String) {
_channels.value =
sorted(
_channels.value.map {
when {
it.chatId == chatId -> it.copy(isDefault = true)
it.isDefault -> it.copy(isDefault = false)
else -> it
}
},
)
}
private fun update(
chatId: String,
transform: (ChannelInfo) -> ChannelInfo,
) {
_channels.value = sorted(_channels.value.map { if (it.chatId == chatId) transform(it) else it })
}
private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> = private fun sorted(list: List<ChannelInfo>): List<ChannelInfo> =
list.sortedWith( list.sortedWith(
compareByDescending<ChannelInfo> { it.isDefault } compareByDescending<ChannelInfo> { it.isDefault }
@@ -156,7 +156,12 @@ class ChatStore {
private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap()) private val _unread = MutableStateFlow<Map<String, Int>>(emptyMap())
val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow() val unread: StateFlow<Map<String, Int>> = _unread.asStateFlow()
private var localSeq = 0 /** Single lock for the lane/todo/unread maps: they are mutated from the
* UI thread (addPending via send) and the frame-collector thread
* (Dispatchers.Default). A non-atomic read-modify-write loses a frame
* that lands between the read and the write (e.g. a streaming delta
* dropped while the user sends). */
private val lock = Any()
/** When false, `message.start`/`message.update` frames are ignored and each /** When false, `message.start`/`message.update` frames are ignored and each
* reply materializes as a single final message on `message.stop` * reply materializes as a single final message on `message.stop`
@@ -199,9 +204,26 @@ class ChatStore {
lane: String, lane: String,
transform: (List<ChatItem>) -> List<ChatItem>, transform: (List<ChatItem>) -> List<ChatItem>,
) { ) {
synchronized(lock) {
val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty())
_lanes.value = map
}
}
/** Apply [transform] to every lane, writing back only when something
* changed. Callers must hold [lock]. */
private fun mapLanes(transform: (List<ChatItem>) -> List<ChatItem>) {
val map = _lanes.value.toMutableMap() val map = _lanes.value.toMutableMap()
map[lane] = transform(map[lane].orEmpty()) var changed = false
_lanes.value = map for ((lane, list) in map) {
val updated = transform(list)
if (updated != list) {
map[lane] = updated
changed = true
}
}
if (changed) _lanes.value = map
} }
// ── Optimistic send ─────────────────────────────────────────────────── // ── Optimistic send ───────────────────────────────────────────────────
@@ -230,8 +252,10 @@ class ChatStore {
lane: String, lane: String,
text: String, text: String,
) { ) {
localSeq++ // randomId (not a process-local seq): system messages are persisted,
val id = "sys_$localSeq" // and a seq that resets on restart would re-mint sys_0 and collide
// with the restored one (upsert overwrite).
val id = randomId("sys")
updateLane(lane) { updateLane(lane) {
it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true) it + MessageItem(id = id, role = "system", text = text, ts = nowMillis(), isSystem = true)
} }
@@ -354,16 +378,18 @@ class ChatStore {
val (chatId, threadId) = parseLane(lane) val (chatId, threadId) = parseLane(lane)
if (threadId == null) return if (threadId == null) return
val flatLane = chatId val flatLane = chatId
val map = _lanes.value.toMutableMap() synchronized(lock) {
val flatList = map[flatLane].orEmpty() val map = _lanes.value.toMutableMap()
val idx = val flatList = map[flatLane].orEmpty()
flatList.indexOfLast { val idx =
it is MessageItem && it.role == ROLE_USER && it.text == p.text && flatList.indexOfLast {
(it.pending || it.status == MsgStatus.Failed) it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
} (it.pending || it.status == MsgStatus.Failed)
if (idx < 0) return }
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) } if (idx < 0) return@synchronized
_lanes.value = map map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
_lanes.value = map
}
} }
/** Merge server media refs into existing items, keeping local paths. */ /** Merge server media refs into existing items, keeping local paths. */
@@ -540,9 +566,11 @@ class ChatStore {
frame: Frame, frame: Frame,
) { ) {
val p = frame.payloadAs<TodoUpdatePayload>() ?: return val p = frame.payloadAs<TodoUpdatePayload>() ?: return
val map = _todos.value.toMutableMap() synchronized(lock) {
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos val map = _todos.value.toMutableMap()
_todos.value = map if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
_todos.value = map
}
} }
// ── commentary (dimmed interim beat) ────────────────────────────────── // ── commentary (dimmed interim beat) ──────────────────────────────────
@@ -623,10 +651,8 @@ class ChatStore {
mediaId: String, mediaId: String,
localPath: String, localPath: String,
) { ) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem) { if (item is MessageItem) {
item.copy( item.copy(
@@ -639,12 +665,8 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
// ── picker.choice (interactive slash-command menu) ────────────────────── // ── picker.choice (interactive slash-command menu) ──────────────────────
@@ -682,10 +704,8 @@ class ChatStore {
pickerId: String, pickerId: String,
value: String, value: String,
) { ) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is PickerItem && item.id == pickerId && item.selected == null) { if (item is PickerItem && item.id == pickerId && item.selected == null) {
item.copy(selected = value) item.copy(selected = value)
@@ -693,27 +713,27 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M8: a new message arrived in [lane] that the user hasn't seen — /** M8: a new message arrived in [lane] that the user hasn't seen —
* increment its unread count. */ * increment its unread count. */
fun markUnread(lane: String) { fun markUnread(lane: String) {
val map = _unread.value.toMutableMap() synchronized(lock) {
map[lane] = (map[lane] ?: 0) + 1 val map = _unread.value.toMutableMap()
_unread.value = map map[lane] = (map[lane] ?: 0) + 1
_unread.value = map
}
} }
/** M8: the user is now viewing [lane]'s newest content — clear its unread /** M8: the user is now viewing [lane]'s newest content — clear its unread
* count. Idempotent (a lane with no unread is a no-op). */ * count. Idempotent (a lane with no unread is a no-op). */
fun markLaneRead(lane: String) { fun markLaneRead(lane: String) {
val map = _unread.value.toMutableMap() synchronized(lock) {
if (map.remove(lane) != null) _unread.value = map val map = _unread.value.toMutableMap()
if (map.remove(lane) != null) _unread.value = map
}
} }
/** M8: unread count for a single lane (0 when none). */ /** M8: unread count for a single lane (0 when none). */
@@ -721,10 +741,8 @@ class ChatStore {
/** M5: mark the user message [messageId] as read (read.receipt). */ /** M5: mark the user message [messageId] as read (read.receipt). */
fun markRead(messageId: String) { fun markRead(messageId: String) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Read item.status != MsgStatus.Read
@@ -734,22 +752,16 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: mark a single user message as failed (the send never reached the /** M7: mark a single user message as failed (the send never reached the
* gateway — network drop, or the gateway rejected it); tap the bubble * gateway — network drop, or the gateway rejected it); tap the bubble
* to retry. */ * to retry. */
fun failMessage(messageId: String) { fun failMessage(messageId: String) {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.id == messageId && item.role == ROLE_USER && if (item is MessageItem && item.id == messageId && item.role == ROLE_USER &&
item.status != MsgStatus.Failed item.status != MsgStatus.Failed
@@ -759,12 +771,8 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** /**
@@ -775,16 +783,9 @@ class ChatStore {
*/ */
fun removeMessages(messageIds: Set<String>) { fun removeMessages(messageIds: Set<String>) {
if (messageIds.isEmpty()) return if (messageIds.isEmpty()) return
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list -> list.filterNot { it.id in messageIds } }
for ((lane, list) in map) {
val updated = list.filterNot { it.id in messageIds }
if (updated != list) {
map[lane] = updated
changed = true
}
} }
if (changed) _lanes.value = map
} }
/** /**
@@ -795,10 +796,8 @@ class ChatStore {
* message.stop) will never arrive to close them. * message.stop) will never arrive to close them.
*/ */
fun finalizeInterrupted() { fun finalizeInterrupted() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
when (item) { when (item) {
is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item is ToolItem -> if (!item.done) item.copy(done = true, ok = false) else item
@@ -806,20 +805,14 @@ class ChatStore {
is PickerItem -> item is PickerItem -> item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: mark all pending user messages as failed (gateway error frame). */ /** M7: mark all pending user messages as failed (gateway error frame). */
fun failPending() { fun failPending() {
val map = _lanes.value.toMutableMap() synchronized(lock) {
var changed = false mapLanes { list ->
for ((lane, list) in map) {
val updated =
list.map { item -> list.map { item ->
if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) { if (item is MessageItem && item.role == ROLE_USER && item.status == MsgStatus.Pending) {
item.copy(pending = false, status = MsgStatus.Failed) item.copy(pending = false, status = MsgStatus.Failed)
@@ -827,12 +820,8 @@ class ChatStore {
item item
} }
} }
if (updated != list) {
map[lane] = updated
changed = true
} }
} }
if (changed) _lanes.value = map
} }
/** M7: re-arm a failed user message for a retry send. */ /** M7: re-arm a failed user message for a retry send. */
@@ -924,11 +913,27 @@ class ChatStore {
*/ */
fun loadFromCache(lanes: Map<String, List<ChatItem>>) { fun loadFromCache(lanes: Map<String, List<ChatItem>>) {
if (lanes.isEmpty()) return if (lanes.isEmpty()) return
_lanes.value = lanes synchronized(lock) {
// The cache is ordered by (ts, id); pending/failed sends are
// persisted with ts = 0, so the DB returns them FIRST — but in
// memory addPending appends them to the END of the lane. Move the
// ts=0 message bubbles to the end (preserving their relative
// order); everything else keeps its stored order, so a restored
// lane looks like the live one until loadHistory re-sorts it
// after a connect.
_lanes.value =
lanes.mapValues { (_, items) ->
val (zeroTs, rest) = items.partition { (it as? MessageItem)?.ts == 0L }
rest + zeroTs
}
}
} }
fun clear() { fun clear() {
_lanes.value = emptyMap() synchronized(lock) {
_unread.value = emptyMap() _lanes.value = emptyMap()
_unread.value = emptyMap()
_todos.value = emptyMap()
}
} }
} }
@@ -2,8 +2,8 @@ package iris.data
/** /**
* Pairing settings storage. The token is a secret: platform actuals keep it * Pairing settings storage. The token is a secret: platform actuals keep it
* in secure storage (EncryptedSharedPreferences on Android — M5; plain * in secure storage (EncryptedSharedPreferences on Android, OS keyring or an
* SharedPreferences for M1 dev, file on desktop). * encrypted file on desktop).
*/ */
interface SecureStore { interface SecureStore {
/** http(s)://host:port (legacy ws(s):// URLs are still accepted) */ /** http(s)://host:port (legacy ws(s):// URLs are still accepted) */
@@ -1,10 +0,0 @@
package iris.media
/** A readable local file (expect/actual; JVM impl in jvmMain). */
expect class FileSource(path: String) : AutoCloseable {
/** Total size in bytes. */
fun size(): Long
/** Read up to [buf.size] bytes into [buf]; returns bytes read or -1 at EOF. */
fun read(buf: ByteArray): Int
}
@@ -6,32 +6,43 @@ import iris.protocol.KIND_IMAGE
import iris.protocol.KIND_VIDEO import iris.protocol.KIND_VIDEO
/** Map a MIME type to a media kind (docs/07 §7.1). */ /** Map a MIME type to a media kind (docs/07 §7.1). */
fun kindFromMime(mime: String): String = when { fun kindFromMime(mime: String): String =
mime.startsWith("image/") -> KIND_IMAGE when {
mime.startsWith("video/") -> KIND_VIDEO mime.startsWith("image/") -> KIND_IMAGE
mime.startsWith("audio/") -> KIND_AUDIO mime.startsWith("video/") -> KIND_VIDEO
else -> KIND_DOCUMENT mime.startsWith("audio/") -> KIND_AUDIO
} else -> KIND_DOCUMENT
}
/** Best-effort file extension for a MIME type (cache file naming). */ /** Best-effort file extension for a MIME type (cache file naming). */
fun extForMime(mime: String): String = when { fun extForMime(mime: String): String =
mime == "image/jpeg" -> ".jpg" when {
mime == "image/png" -> ".png" mime == "image/jpeg" -> ".jpg"
mime == "image/webp" -> ".webp" mime == "image/png" -> ".png"
mime == "image/gif" -> ".gif" mime == "image/webp" -> ".webp"
mime == "image/heic" -> ".heic" mime == "image/gif" -> ".gif"
mime == "image/heif" -> ".heif" mime == "image/heic" -> ".heic"
mime == "video/mp4" -> ".mp4" mime == "image/heif" -> ".heif"
mime == "video/webm" -> ".webm" mime == "video/mp4" -> ".mp4"
mime == "video/quicktime" -> ".mov" mime == "video/webm" -> ".webm"
mime == "audio/mpeg" -> ".mp3" mime == "video/quicktime" -> ".mov"
mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a" mime == "audio/mpeg" -> ".mp3"
mime == "audio/ogg" -> ".ogg" mime == "audio/mp4" || mime == "audio/x-m4a" -> ".m4a"
mime == "audio/wav" -> ".wav" mime == "audio/ogg" -> ".ogg"
mime == "audio/flac" -> ".flac" mime == "audio/wav" -> ".wav"
mime == "audio/aac" -> ".aac" mime == "audio/flac" -> ".flac"
mime == "application/pdf" -> ".pdf" mime == "audio/aac" -> ".aac"
mime == "application/zip" -> ".zip" mime == "application/pdf" -> ".pdf"
mime == "text/plain" -> ".txt" mime == "application/zip" -> ".zip"
else -> ".bin" mime == "text/plain" -> ".txt"
} else -> ".bin"
}
/**
* Validate a server-provided media id before it is used in a file path or a
* `GET /v1/media/{id}` URL (M-2 / S-1). The id comes from the gateway's
* `media.offer` frame; a value like `../../x` would write outside the media
* directory and break the pull URL. Only a conservative token charset is
* accepted.
*/
fun isValidMediaId(id: String): Boolean = id.length in 1..128 && id.all { it.isLetterOrDigit() || it == '_' || it == '-' }
@@ -13,7 +13,6 @@ import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.currentCoroutineContext import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
@@ -27,7 +26,6 @@ import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withTimeout
import kotlinx.coroutines.withTimeoutOrNull import kotlinx.coroutines.withTimeoutOrNull
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -42,7 +40,9 @@ import kotlin.random.Random
* - connect: health probe + SSE hello (the HTTP hello.ack) * - connect: health probe + SSE hello (the HTTP hello.ack)
* - reconnect: exponential backoff + jitter; re-hello on every (re)connect * - reconnect: exponential backoff + jitter; re-hello on every (re)connect
* - events: server frames on [events] * - events: server frames on [events]
* - request/response correlation by id * - correlation: the POST body carries the synchronous reply (e.g. read
* receipt, errors); everything else arrives on the event stream, and the
* app reconciles by frame id (docs/19 §19.7)
*/ */
class GatewayClient( class GatewayClient(
private val scope: CoroutineScope, private val scope: CoroutineScope,
@@ -87,6 +87,14 @@ class GatewayClient(
private var connectJob: Job? = null private var connectJob: Job? = null
private var nextRequestId = 1 private var nextRequestId = 1
// Incremented from the SSE callback thread (onHttpHello) and the UI
// thread (sendFrame/sendMessage) — keep it atomic or ids collide and
// request/response correlation breaks.
private fun nextId(): Int = synchronized(this) { nextRequestId++ }
// Written by poke() (UI thread) and the connect loop (Default).
@Volatile
private var attempt = 0 private var attempt = 0
// Set by poke() (app returned to the foreground): the connect loop's // Set by poke() (app returned to the foreground): the connect loop's
@@ -98,17 +106,21 @@ class GatewayClient(
// start(). Drives Connecting (first dial) vs Reconnecting (redial after a // start(). Drives Connecting (first dial) vs Reconnecting (redial after a
// drop) so the UI can show the right status without a blocking screen. // drop) so the UI can show the right status without a blocking screen.
private var hasConnected = false private var hasConnected = false
private val pending = mutableMapOf<Int, CompletableDeferred<Frame>>()
// HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is // HTTP leg: [http] is created lazily from the stored URL; [httpCursor] is
// the resume cursor (SSE id / outbox high-water mark). // the resume cursor (SSE id / outbox high-water mark), updated from the
// SSE/poll callback threads.
private var http: HttpGateway? = null private var http: HttpGateway? = null
@Volatile
private var httpCursor: Long = 0 private var httpCursor: Long = 0
private var sseFailures = 0 private var sseFailures = 0
private var usingLongPoll = false private var usingLongPoll = false
// Last hello.ack payload — used to restore State.Connected after a // Last hello.ack payload — used to restore State.Connected after a
// reconnect state race in the connect loop. // reconnect state race in the connect loop. Written by the SSE callback
// thread, read by the long-poll loop.
@Volatile
private var lastAck: HelloAckPayload? = null private var lastAck: HelloAckPayload? = null
// Epoch ms of the last frame delivered by the receive stream (SSE or // Epoch ms of the last frame delivered by the receive stream (SSE or
@@ -144,11 +156,22 @@ class GatewayClient(
connectJob = scope.launch { connectLoop() } connectJob = scope.launch { connectLoop() }
} }
/** Stop the connect loop. */ /**
* Stop the connect loop and reset the HTTP leg. Without the reset, a
* re-pair to a *different* gateway would keep using the cached
* [HttpGateway] (old URL, old token, old resume cursor) until the process
* is killed.
*/
fun stop() { fun stop() {
connectJob?.cancel() connectJob?.cancel()
connectJob = null connectJob = null
_state.value = State.Disconnected _state.value = State.Disconnected
http?.close()
http = null
httpCursor = 0
sseFailures = 0
usingLongPoll = false
lastAck = null
} }
/** /**
@@ -249,22 +272,25 @@ class GatewayClient(
// ── HTTP receive leg ────────────────────────────────────────────────── // ── HTTP receive leg ──────────────────────────────────────────────────
/** Lazily build the HTTP client from the stored URL. */ /** Lazily build the HTTP client from the stored URL. Synchronized: two
private fun httpGateway(): HttpGateway? { * threads racing the check-then-create would leak a gateway (and its
val url = store.serverUrl.trim() * OkHttp clients). */
val token = store.token private fun httpGateway(): HttpGateway? =
if (url.isBlank() || token.isBlank()) return null synchronized(this) {
return http val url = store.serverUrl.trim()
?: HttpGateway( val token = store.token
client, if (url.isBlank() || token.isBlank()) return@synchronized null
HttpGateway.deriveHttpUrl(url), http
token, ?: HttpGateway(
store.deviceId, client,
deviceName = store.deviceName, HttpGateway.deriveHttpUrl(url),
fcmToken = { store.fcmToken.ifBlank { null } }, token,
ntfyTopic = { store.ntfyTopic.ifBlank { null } }, store.deviceId,
).also { http = it } deviceName = store.deviceName,
} fcmToken = { store.fcmToken.ifBlank { null } },
ntfyTopic = { store.ntfyTopic.ifBlank { null } },
).also { http = it }
}
/** /**
* The HTTP receive loop: SSE by default; after two consecutive SSE open * The HTTP receive loop: SSE by default; after two consecutive SSE open
@@ -298,9 +324,16 @@ class GatewayClient(
onHello = { onHttpHello(it) }, onHello = { onHttpHello(it) },
onFrame = { emitHttpFrame(it) }, onFrame = { emitHttpFrame(it) },
onCursor = { if (it > httpCursor) httpCursor = it }, onCursor = { if (it > httpCursor) httpCursor = it },
// A keep-alive comment proves the stream is alive —
// count it toward liveness so an idle-but-healthy
// stream isn't force-reconnected by the stale
// watchdog every 3 minutes.
onKeepAlive = { lastFrameMs = nowMs() },
) )
// Clean EOF: reconnect immediately. // Clean EOF: back off briefly so a server that keeps
// closing cleanly can't tight-loop the reconnect.
backoff = 1_000L backoff = 1_000L
delay(backoff)
} catch (e: HttpGateway.HttpAuthException) { } catch (e: HttpGateway.HttpAuthException) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)") _state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
return return
@@ -328,7 +361,7 @@ class GatewayClient(
// M5: reconnect catch-up — replay frames parked while offline. // M5: reconnect catch-up — replay frames parked while offline.
val local = store.syncCursor val local = store.syncCursor
if (local < ack.syncCursor) { if (local < ack.syncCursor) {
val id = nextRequestId++ val id = nextId()
scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) } scope.launch { httpGateway()?.postFrame(syncFrame(id, local)) }
} }
// Prompt fast path (before the possibly-starved state collector). // Prompt fast path (before the possibly-starved state collector).
@@ -361,9 +394,20 @@ class GatewayClient(
/** Deliver an HTTP-leg frame to the same sinks as any other frame. */ /** Deliver an HTTP-leg frame to the same sinks as any other frame. */
private fun emitHttpFrame(frame: Frame) { private fun emitHttpFrame(frame: Frame) {
lastFrameMs = nowMs() lastFrameMs = nowMs()
_events.tryEmit(frame) if (!_events.tryEmit(frame)) {
frame.id?.let { id -> // The collector is starved beyond the 128-frame buffer: the frame
pending[id]?.complete(frame) // is dropped. Log it — a silent drop here loses user-visible
// content (echoes, deltas, notifications).
IrisLog.e("events buffer full — dropped frame ${frame.type} (id=${frame.id})")
}
}
/** Deliver the POST body's synchronous reply (or null) and handle a 401.
* Shared by [sendMessage] and [sendFrame]. */
private fun handlePostResult(res: HttpGateway.PostResult?) {
res?.frame?.let { emitHttpFrame(it) }
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
} }
} }
@@ -390,7 +434,7 @@ class GatewayClient(
onResult?.invoke(0) onResult?.invoke(0)
return return
} }
val id = nextRequestId++ val id = nextId()
scope.launch { scope.launch {
val res = val res =
httpGateway()?.postFrame( httpGateway()?.postFrame(
@@ -399,10 +443,7 @@ class GatewayClient(
// The synchronous reply (e.g. the read receipt, or an error frame // The synchronous reply (e.g. the read receipt, or an error frame
// on 4xx) comes back in the POST body, not on the event stream — // on 4xx) comes back in the POST body, not on the event stream —
// deliver it or it is lost (docs/19 §19.7). // deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) } handlePostResult(res)
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
onResult?.invoke(res?.status ?: 0) onResult?.invoke(res?.status ?: 0)
} }
} }
@@ -438,10 +479,8 @@ class GatewayClient(
} }
companion object { companion object {
const val UPLOAD_TIMEOUT_MS = 120_000L /** No frames (or keep-alive comments) from the receive stream for
const val PULL_TIMEOUT_MS = 300_000L * this long (gateway still
/** No frames from the receive stream for this long (gateway still
* healthy) = stale stream: force a fresh (re)connect. 3 min keeps an * healthy) = stale stream: force a fresh (re)connect. 3 min keeps an
* idle app from churning while still recovering a stuck stream well * idle app from churning while still recovering a stuck stream well
* before a user notices (issue #6). */ * before a user notices (issue #6). */
@@ -455,16 +494,13 @@ class GatewayClient(
*/ */
fun sendFrame(frame: Frame): Int { fun sendFrame(frame: Frame): Int {
if (_state.value !is State.Connected) return -1 if (_state.value !is State.Connected) return -1
val id = nextRequestId++ val id = nextId()
scope.launch { scope.launch {
val res = httpGateway()?.postFrame(frame.copy(id = id)) val res = httpGateway()?.postFrame(frame.copy(id = id))
// Single-frame responses (commands.catalog, channel.list, search, // Single-frame responses (commands.catalog, channel.list, search,
// history, sync, errors) come back in the POST body, not on the // history, sync, errors) come back in the POST body, not on the
// event stream — deliver it or it is lost (docs/19 §19.7). // event stream — deliver it or it is lost (docs/19 §19.7).
res?.frame?.let { emitHttpFrame(it) } handlePostResult(res)
if (res?.status == 401) {
_state.value = State.AuthFailed("gateway rejected the pairing token (HTTP 401)")
}
} }
return id return id
} }
@@ -1,6 +1,7 @@
package iris.net package iris.net
import iris.media.Sha256 import iris.media.Sha256
import iris.media.isValidMediaId
import iris.protocol.ErrorPayload import iris.protocol.ErrorPayload
import iris.protocol.Frame import iris.protocol.Frame
import iris.protocol.HelloAckPayload import iris.protocol.HelloAckPayload
@@ -60,6 +61,19 @@ class HttpGateway(
private val pollClient: OkHttpClient = client.pollClient() private val pollClient: OkHttpClient = client.pollClient()
private val mediaClient: OkHttpClient = client.mediaClient() private val mediaClient: OkHttpClient = client.mediaClient()
/** Close the per-purpose clients (and their idle connections). The
* shared base [client] is owned by the caller. */
fun close() {
healthClient.dispatcher.executorService.shutdown()
streamClient.dispatcher.executorService.shutdown()
pollClient.dispatcher.executorService.shutdown()
mediaClient.dispatcher.executorService.shutdown()
healthClient.connectionPool.evictAll()
streamClient.connectionPool.evictAll()
pollClient.connectionPool.evictAll()
mediaClient.connectionPool.evictAll()
}
/** POST /v1/frame result. [frame] is the handler's synchronous reply /** POST /v1/frame result. [frame] is the handler's synchronous reply
* (error frame on 4xx, e.g. read.receipt on 200) or null for a plain * (error frame on 4xx, e.g. read.receipt on 200) or null for a plain
* 202 accept-and-ack. */ * 202 accept-and-ack. */
@@ -195,8 +209,10 @@ class HttpGateway(
* leg is proven; the server may still replay a large outbox before the * leg is proven; the server may still replay a large outbox before the
* hello); [onHello] fires for `event: hello` (the HTTP hello.ack); * hello); [onHello] fires for `event: hello` (the HTTP hello.ack);
* [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox * [onFrame] for `event: frame`; [onCursor] with the SSE `id` (outbox
* cursor) when present. Returns on clean EOF; throws [IOException] on * cursor) when present; [onKeepAlive] for SSE comment lines (the
* open/read failure. Callbacks run on the IO thread. * heartbeat) so callers can count keep-alives toward liveness. Returns
* on clean EOF; throws [IOException] on open/read failure. Callbacks run
* on the IO thread.
*/ */
suspend fun events( suspend fun events(
cursor: Long, cursor: Long,
@@ -204,6 +220,7 @@ class HttpGateway(
onHello: (HelloAckPayload) -> Unit, onHello: (HelloAckPayload) -> Unit,
onFrame: (Frame) -> Unit, onFrame: (Frame) -> Unit,
onCursor: (Long) -> Unit, onCursor: (Long) -> Unit,
onKeepAlive: (() -> Unit)? = null,
) { ) {
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
val request = val request =
@@ -257,10 +274,10 @@ class HttpGateway(
} }
line.startsWith(":") -> { line.startsWith(":") -> {
Unit // heartbeat comment
onKeepAlive?.invoke()
} }
// heartbeat comment
line.startsWith("id:") -> { line.startsWith("id:") -> {
line line
.removePrefix("id:") .removePrefix("id:")
@@ -399,6 +416,11 @@ class HttpGateway(
onChunk: suspend (ByteArray) -> Unit, onChunk: suspend (ByteArray) -> Unit,
): Result<Unit> = ): Result<Unit> =
withContext(Dispatchers.IO) { withContext(Dispatchers.IO) {
// Server-controlled id: reject path-traversal / URL-breaking
// values before they reach the request line (M-2 / S-1).
if (!isValidMediaId(mediaId)) {
return@withContext Result.failure(IllegalStateException("invalid media id"))
}
val request = val request =
Request Request
.Builder() .Builder()
@@ -6,7 +6,7 @@ package iris.platform
* The controller (commonMain) needs to know whether the app is in the * The controller (commonMain) needs to know whether the app is in the
* foreground (to decide between an in-app banner and a system notification) * foreground (to decide between an in-app banner and a system notification)
* and to post a system notification when a `notification` frame arrives while * and to post a system notification when a `notification` frame arrives while
* the app is backgrounded but the WS is still live. * the app is backgrounded but the gateway connection is still live.
*/ */
/** True when the app's UI is visible (Android: activity resumed). */ /** True when the app's UI is visible (Android: activity resumed). */
@@ -12,9 +12,9 @@ import kotlinx.serialization.json.put
/** /**
* Wire protocol frames (mirror of gateway-plugin/protocol.py). * Wire protocol frames (mirror of gateway-plugin/protocol.py).
* See docs/04-wire-protocol.md. M1: hello/hello.ack, message, message.send, * See docs/04-wire-protocol.md. Defines all frame types and payloads:
* error, ping/pong, typing. M2: message.start/update/stop, tool.start/ * hello/hello.ack, message (send + streaming), tool cards, commentary,
* progress/end, commentary, reasoning (on message / message.stop). * reasoning, channels, media, notifications, sync, and error frames.
*/ */
const val PROTOCOL_VERSION = 1 const val PROTOCOL_VERSION = 1
@@ -89,20 +89,11 @@ const val TYPE_SYNC = "sync"
const val TYPE_SYNC_DONE = "sync.done" const val TYPE_SYNC_DONE = "sync.done"
const val TYPE_HISTORY = "history" const val TYPE_HISTORY = "history"
// ── Error codes ─────────────────────────────────────────────────────────
const val ERR_AUTH = "auth"
const val ERR_NOT_FOUND = "not_found"
const val ERR_UNSUPPORTED = "unsupported"
const val ERR_INTERNAL = "internal"
const val ERR_MEDIA_TOO_LARGE = "media_too_large"
// M4 — media kinds (docs/07 §7.1) // M4 — media kinds (docs/07 §7.1)
const val KIND_IMAGE = "image" const val KIND_IMAGE = "image"
const val KIND_AUDIO = "audio" const val KIND_AUDIO = "audio"
const val KIND_VIDEO = "video" const val KIND_VIDEO = "video"
const val KIND_DOCUMENT = "document" const val KIND_DOCUMENT = "document"
const val KIND_VOICE = "voice"
// ── Roles ─────────────────────────────────────────────────────────────── // ── Roles ───────────────────────────────────────────────────────────────
@@ -516,12 +507,9 @@ data class MessageDeletedPayload(
// ── M5: push / notifications ──────────────────────────────────────────── // ── M5: push / notifications ────────────────────────────────────────────
/** Notification kinds (mirror of protocol.NOTIF_*). */ /** Notification kinds (mirror of protocol.NOTIF_*). */
const val NOTIF_MESSAGE = "message"
const val NOTIF_APPROVAL = "approval" const val NOTIF_APPROVAL = "approval"
const val NOTIF_CLARIFY = "clarify" const val NOTIF_CLARIFY = "clarify"
const val NOTIF_CRON = "cron" const val NOTIF_CRON = "cron"
const val NOTIF_CHANNEL = "channel"
const val NOTIF_OUTBOX_PRUNED = "outbox_pruned"
/** Kinds that stay on screen until dismissed (docs/08 §8.3). */ /** Kinds that stay on screen until dismissed (docs/08 §8.3). */
val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON) val HIGH_PRIORITY_NOTIF_KINDS = setOf(NOTIF_APPROVAL, NOTIF_CLARIFY, NOTIF_CRON)
@@ -8,6 +8,7 @@ import iris.data.MessageItem
import iris.data.MsgStatus import iris.data.MsgStatus
import iris.data.SecureStore import iris.data.SecureStore
import iris.media.MediaCache import iris.media.MediaCache
import iris.media.isValidMediaId
import iris.media.kindFromMime import iris.media.kindFromMime
import iris.net.GatewayClient import iris.net.GatewayClient
import iris.platform.PickedFile import iris.platform.PickedFile
@@ -45,7 +46,6 @@ import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_MEDIA_OFFER import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
import iris.protocol.TYPE_MESSAGE_DELETED import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP import iris.protocol.TYPE_MESSAGE_STOP
@@ -90,6 +90,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Collections
import java.util.concurrent.atomic.AtomicLong
import kotlin.random.Random import kotlin.random.Random
/** /**
@@ -144,8 +146,9 @@ class IrisController(
/** Lanes whose full history has been loaded this session (in-memory; reset /** Lanes whose full history has been loaded this session (in-memory; reset
* on a process death, which is exactly when a reload is needed). The * on a process death, which is exactly when a reload is needed). The
* `sync` delta can seed a lane with recent frames without it being opened, * `sync` delta can seed a lane with recent frames without it being opened,
* so "lane is empty" is not a reliable first-open signal. */ * so "lane is empty" is not a reliable first-open signal. Synchronized:
private val historyLoaded = mutableSetOf<String>() * written by the frame collector, read by the UI thread (loadHistory). */
private val historyLoaded = Collections.synchronizedSet(mutableSetOf<String>())
/** Gateway health state (M5: status frame; null = never received). /** Gateway health state (M5: status frame; null = never received).
* Note: "restarting" is deliberately NOT stored here — it posts the * Note: "restarting" is deliberately NOT stored here — it posts the
@@ -300,6 +303,9 @@ class IrisController(
} }
companion object { companion object {
/** Max simultaneous banners; persistent ones are exempt from the cap. */
private const val MAX_BANNERS = 5
const val FONT_SCALE_MIN = 0.8f const val FONT_SCALE_MIN = 0.8f
const val FONT_SCALE_MAX = 1.5f const val FONT_SCALE_MAX = 1.5f
@@ -411,8 +417,12 @@ class IrisController(
// ── M4: media ───────────────────────────────────────────────────────── // ── M4: media ─────────────────────────────────────────────────────────
private val mediaCache = MediaCache(mediaCacheBaseDir()) private val mediaCache = MediaCache(mediaCacheBaseDir())
/** A composer attachment: picked file being uploaded (or uploaded). */ /** A composer attachment: picked file being uploaded (or uploaded).
* [id] is a per-attachment identity used to apply the upload result to
* the right placeholder — matching by filename would cross-update two
* files picked with the same name (M-6). */
data class PendingAttachment( data class PendingAttachment(
val id: String,
val filename: String, val filename: String,
val mime: String, val mime: String,
val size: Long, val size: Long,
@@ -441,7 +451,10 @@ class IrisController(
private val _banners = MutableStateFlow<List<Banner>>(emptyList()) private val _banners = MutableStateFlow<List<Banner>>(emptyList())
val banners: StateFlow<List<Banner>> = _banners.asStateFlow() val banners: StateFlow<List<Banner>> = _banners.asStateFlow()
private var bannerSeq = 0L
// Atomic: pushBanner can be called from the frame collector and the UI
// thread; a lost update would mint a duplicate banner id.
private val bannerSeq = AtomicLong(0)
fun dismissBanner(id: Long) { fun dismissBanner(id: Long) {
_banners.value = _banners.value.filterNot { it.id == id } _banners.value = _banners.value.filterNot { it.id == id }
@@ -455,8 +468,13 @@ class IrisController(
threadId: String?, threadId: String?,
) { ) {
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent) val banner = Banner(bannerSeq.getAndIncrement(), kind, title, body, chatId, threadId, persistent)
_banners.value = (_banners.value + banner).takeLast(5) // Persistent banners (approval / clarify / cron) are never evicted by
// the cap; only the transient ones compete for the remaining slots.
val merged = _banners.value + banner
val keptPersistent = merged.filter { it.persistent }
val room = (MAX_BANNERS - keptPersistent.size).coerceAtLeast(0)
_banners.value = keptPersistent + merged.filterNot { it.persistent }.takeLast(room)
if (!persistent) { if (!persistent) {
scope.launch { scope.launch {
delay(5_000) delay(5_000)
@@ -723,7 +741,7 @@ class IrisController(
) )
// Mark the lane loaded only when the response // Mark the lane loaded only when the response
// is actually processed: if the request or // is actually processed: if the request or
// response is lost in a WS drop, the lane // response is lost in a connection drop, the lane
// stays unmarked and the next (re)connect // stays unmarked and the next (re)connect
// retries it. // retries it.
historyLoaded.add(lane) historyLoaded.add(lane)
@@ -743,7 +761,7 @@ class IrisController(
// sync replay) must not re-show the banner. // sync replay) must not re-show the banner.
if (!alreadyConsumed) { if (!alreadyConsumed) {
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId) pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
// M5: WS is live but the app is backgrounded — the // M5: the connection is live but the app is backgrounded — the
// in-app banner is invisible, so mirror to a system // in-app banner is invisible, so mirror to a system
// notification (the push backend only fires when // notification (the push backend only fires when
// there is no live subscriber). Suppressed for // there is no live subscriber). Suppressed for
@@ -777,7 +795,7 @@ class IrisController(
if (st.state == "restarting") { if (st.state == "restarting") {
// Gateway is going down (restart/stop): // Gateway is going down (restart/stop):
// post the notice IMMEDIATELY — the // post the notice IMMEDIATELY — the
// socket can take up to the ping timeout // connection can take up to the ping timeout
// (~20 s) to actually drop, and waiting // (~20 s) to actually drop, and waiting
// for that transition would delay the // for that transition would delay the
// message. No banner for this state: the // message. No banner for this state: the
@@ -883,7 +901,7 @@ class IrisController(
} }
/** /**
* Fast-path connect handler (runs on the WS thread via [GatewayClient.onHelloAck], * Fast-path connect handler (runs on the gateway callback thread via [GatewayClient.onHelloAck],
* promptly on every (re)connect). Seeds the channel directory and loads the * promptly on every (re)connect). Seeds the channel directory and loads the
* active lane's full history. The lane is the last-viewed one (restored from * active lane's full history. The lane is the last-viewed one (restored from
* the local cache) if it still exists on the server, else the home channel. * the local cache) if it still exists on the server, else the home channel.
@@ -970,6 +988,7 @@ class IrisController(
} }
fun setDefaultChannel(chatId: String) { fun setDefaultChannel(chatId: String) {
channels.setDefault(chatId)
client.sendFrame(channelSetDefaultFrame(0, chatId)) client.sendFrame(channelSetDefaultFrame(0, chatId))
} }
@@ -978,6 +997,7 @@ class IrisController(
chatId: String, chatId: String,
on: Boolean, on: Boolean,
) { ) {
channels.setFavorite(chatId, on)
client.sendFrame(channelFavoriteFrame(0, chatId, on)) client.sendFrame(channelFavoriteFrame(0, chatId, on))
} }
@@ -987,6 +1007,7 @@ class IrisController(
chatId: String, chatId: String,
on: Boolean, on: Boolean,
) { ) {
channels.setAutomation(chatId, on)
client.sendFrame(channelSetAutomationFrame(0, chatId, on)) client.sendFrame(channelSetAutomationFrame(0, chatId, on))
} }
@@ -997,6 +1018,7 @@ class IrisController(
icon: String?, icon: String?,
color: String?, color: String?,
) { ) {
channels.setIcon(chatId, icon, color)
client.sendFrame(channelIconFrame(0, chatId, icon, color)) client.sendFrame(channelIconFrame(0, chatId, icon, color))
} }
@@ -1055,9 +1077,10 @@ class IrisController(
val lane = chat.laneKey(chatId, threadId) val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return if (lane in historyLoaded) return
// Newest page, sized to restore a full working view on restart / first // Newest page, sized to restore a full working view on restart / first
// open (older pages are reachable via scroll-up pagination). The lane // latest page only (older pages are not paginated in the current UI).
// The lane
// is marked loaded when the response arrives (TYPE_HISTORY), not here — // is marked loaded when the response arrives (TYPE_HISTORY), not here —
// a request lost in a WS drop must be retryable on reconnect. // a request lost in a connection drop must be retryable on reconnect.
client.sendFrame(historyFrame(0, chatId, threadId, limit = 200)) client.sendFrame(historyFrame(0, chatId, threadId, limit = 200))
} }
@@ -1150,8 +1173,9 @@ class IrisController(
* gateway error frame): queued (Pending) or failed bubbles that go out * gateway error frame): queued (Pending) or failed bubbles that go out
* automatically on the next (re)connect. In-memory only — a process * automatically on the next (re)connect. In-memory only — a process
* death leaves them as tap-to-retry (the local cache restore already * death leaves them as tap-to-retry (the local cache restore already
* marks pending sends failed). */ * marks pending sends failed). Synchronized: written by the send-result
private val networkFailed = mutableSetOf<String>() * callback (UI thread) and the frame collector. */
private val networkFailed = Collections.synchronizedSet(mutableSetOf<String>())
/** Resend queued/failed user messages of [lane] now that the link is /** Resend queued/failed user messages of [lane] now that the link is
* back: a message the server already has (the POST response was lost in * back: a message the server already has (the POST response was lost in
@@ -1205,8 +1229,13 @@ class IrisController(
/** Stage a picked file: upload it, then keep it as a pending attachment. */ /** Stage a picked file: upload it, then keep it as a pending attachment. */
fun attachFile(picked: PickedFile) { fun attachFile(picked: PickedFile) {
val kind = kindFromMime(picked.mime) val kind = kindFromMime(picked.mime)
// Per-attachment identity: the upload result is applied to THIS
// placeholder by id, so two files with the same name don't
// cross-update (M-6).
val id = "att_${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
val placeholder = val placeholder =
PendingAttachment( PendingAttachment(
id = id,
filename = picked.name, filename = picked.name,
mime = picked.mime, mime = picked.mime,
size = picked.size, size = picked.size,
@@ -1226,7 +1255,7 @@ class IrisController(
) )
_attachments.value = _attachments.value =
_attachments.value.map { _attachments.value.map {
if (it.filename == picked.name && it.uploading) { if (it.id == id && it.uploading) {
result.fold( result.fold(
{ ref -> it.copy(uploading = false, mediaRef = ref) }, { ref -> it.copy(uploading = false, mediaRef = ref) },
{ e -> it.copy(uploading = false, error = e.message) }, { e -> it.copy(uploading = false, error = e.message) },
@@ -1245,6 +1274,12 @@ class IrisController(
/** Pull offered media into the local cache and record the path. */ /** Pull offered media into the local cache and record the path. */
private fun pullMedia(offer: MediaOfferPayload) { private fun pullMedia(offer: MediaOfferPayload) {
scope.launch { scope.launch {
// Server-controlled id: reject path-traversal values before they
// reach the cache or the pull URL (M-2 / S-1).
if (!isValidMediaId(offer.mediaId)) {
IrisLog.w("rejecting media offer with invalid id: ${offer.mediaId.take(40)}")
return@launch
}
// Already cached? Skip the pull. // Already cached? Skip the pull.
mediaCache.path(offer.mediaId, offer.mime)?.let { mediaCache.path(offer.mediaId, offer.mime)?.let {
chat.setMediaLocalPath(offer.mediaId, it) chat.setMediaLocalPath(offer.mediaId, it)
@@ -1283,11 +1318,13 @@ class IrisController(
fun dispose() { fun dispose() {
client.stop() client.stop()
// Final synchronous flush so the newest frames survive the process // M-17: cancel the debounce job FIRST so a pending save can't fire
// death (the debounce window may still hold unsaved changes). // after our final flush and clobber it; then do the final synchronous
// flush so the newest frames survive the process death (the debounce
// window may still hold unsaved changes).
job.cancel()
chatDb.saveLanes(chat.lanes.value) chatDb.saveLanes(chat.lanes.value)
chatDb.saveChannels(channels.channels.value) chatDb.saveChannels(channels.channels.value)
job.cancel()
} }
} }
@@ -118,7 +118,6 @@ import iris.net.GatewayClient
import iris.platform.ImageFilePicker import iris.platform.ImageFilePicker
import iris.platform.MediaFilePicker import iris.platform.MediaFilePicker
import iris.platform.MediaPlayerView import iris.platform.MediaPlayerView
import iris.platform.PickedFile
import iris.platform.decodeBase64Image import iris.platform.decodeBase64Image
import iris.platform.encodeImageAsBase64 import iris.platform.encodeImageAsBase64
import iris.platform.isDesktop import iris.platform.isDesktop
@@ -152,6 +151,7 @@ import iris.util.prepareForMarkdown
import iris.util.preserveNewlinesAsHardBreaks import iris.util.preserveNewlinesAsHardBreaks
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import java.util.Locale
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -1514,7 +1514,9 @@ private fun LetterAvatar(
/** Parse a "#RRGGBB" (or "#AARRGGBB") hex string into a [Color]. */ /** Parse a "#RRGGBB" (or "#AARRGGBB") hex string into a [Color]. */
private fun parseHexColor(hex: String): Color { private fun parseHexColor(hex: String): Color {
val cleaned = hex.removePrefix("#") var cleaned = hex.removePrefix("#")
// Expand 3-digit shorthand (#F00 -> #FF0000) so short hex is accepted.
if (cleaned.length == 3) cleaned = cleaned.map { "$it$it" }.joinToString("")
val withAlpha = if (cleaned.length == 6) "FF$cleaned" else cleaned val withAlpha = if (cleaned.length == 6) "FF$cleaned" else cleaned
return Color(withAlpha.toLongOrNull(16) ?: 0xFF000000L) return Color(withAlpha.toLongOrNull(16) ?: 0xFF000000L)
} }
@@ -2658,13 +2660,19 @@ private fun formatLatency(seconds: Double): String {
if (seconds < 1) return "<1s" if (seconds < 1) return "<1s"
val total = seconds.roundToInt() val total = seconds.roundToInt()
if (total < 60) return "${total}s" if (total < 60) return "${total}s"
val m = total / 60 val h = total / 3600
val m = (total % 3600) / 60
val s = total % 60 val s = total % 60
return "${m}m${s.toString().padStart(2, '0')}s" return if (h > 0) {
"${h}h${m.toString().padStart(2, '0')}m"
} else {
"${m}m${s.toString().padStart(2, '0')}s"
}
} }
/** Format a cost in USD: sub-cent at 4 decimals, else 2. */ /** Format a cost in USD: sub-cent at 4 decimals, else 2. Fixed locale so the
private fun formatCost(cost: Double): String = if (cost < 0.01) "$%.4f".format(cost) else "$%.2f".format(cost) * decimal separator is always a dot (L-12). */
private fun formatCost(cost: Double): String = if (cost < 0.01) "$%.4f".format(Locale.US, cost) else "$%.2f".format(Locale.US, cost)
/** Circular selection indicator shown beside a bubble in selection mode. */ /** Circular selection indicator shown beside a bubble in selection mode. */
@Composable @Composable
@@ -2839,8 +2847,8 @@ private fun MediaDocChip(media: MediaItem) {
private fun fmtSize(bytes: Long): String = private fun fmtSize(bytes: Long): String =
when { when {
bytes >= 1_048_576 -> "%.1f MB".format(bytes / 1_048_576.0) bytes >= 1_048_576 -> "%.1f MB".format(Locale.US, bytes / 1_048_576.0)
bytes >= 1024 -> "%.0f KB".format(bytes / 1024.0) bytes >= 1024 -> "%.0f KB".format(Locale.US, bytes / 1024.0)
else -> "$bytes B" else -> "$bytes B"
} }
@@ -78,6 +78,7 @@ fun SettingsScreen(
val fontSizeScale by controller.fontSizeScale.collectAsState() val fontSizeScale by controller.fontSizeScale.collectAsState()
val theme = LocalUserTheme.current val theme = LocalUserTheme.current
var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) } var pickerTarget by remember { mutableStateOf<PickerTarget?>(null) }
var showForgetConfirm by remember { mutableStateOf(false) }
Box(modifier = Modifier.fillMaxSize().background(theme.background)) { Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column( Column(
@@ -391,6 +392,24 @@ fun SettingsScreen(
} }
} }
} }
Text(
"Connection",
style = MaterialTheme.typography.titleSmall,
modifier = Modifier.padding(top = 12.dp, bottom = 4.dp),
)
SettingsCard {
Text("🔌 Forget pairing", fontSize = 14.sp)
Text(
"Clears the gateway token and wipes local chat history",
fontSize = 12.sp,
color = IrisColors.textDim,
)
Spacer(modifier = Modifier.height(8.dp))
TextButton(onClick = { showForgetConfirm = true }) {
Text("Forget pairing", fontSize = 12.sp)
}
}
} }
when (pickerTarget) { when (pickerTarget) {
@@ -437,6 +456,32 @@ fun SettingsScreen(
Unit Unit
} }
} }
if (showForgetConfirm) {
AlertDialog(
onDismissRequest = { showForgetConfirm = false },
title = { Text("Forget pairing?") },
text = {
Text(
"This clears the gateway token and wipes all local chat history on this device. You'll need to pair again to reconnect.",
fontSize = 13.sp,
)
},
confirmButton = {
TextButton(onClick = {
showForgetConfirm = false
controller.forget()
}) {
Text("Forget")
}
},
dismissButton = {
TextButton(onClick = { showForgetConfirm = false }) {
Text("Cancel")
}
},
)
}
} }
} }
@@ -1,8 +1,9 @@
package iris.ui.theme package iris.ui.theme
/** /**
* A bundled backdrop image shipped in the app (Android `assets/backdrops/`, * A bundled backdrop image shipped in the app (Android: `androidApp` module
* desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes]. * `assets/backdrops/`; desktop: classpath `backdrops/`), loaded via
* [iris.platform.readBackdropBytes].
* *
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the * Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real * sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
@@ -18,9 +19,12 @@ data class Backdrop(
companion object { companion object {
const val PATH_PREFIX = "backdrop://" const val PATH_PREFIX = "backdrop://"
/** Default background for new chats / fresh installs. */
val DEFAULT = Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli")
val ALL: List<Backdrop> = val ALL: List<Backdrop> =
listOf( listOf(
Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"), DEFAULT,
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"), Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
Backdrop("pexels-bosichong-27940302", "Bosi Chong"), Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"), Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
@@ -28,9 +32,6 @@ data class Backdrop(
Backdrop("pexels-steve-29390703", "Steve"), Backdrop("pexels-steve-29390703", "Steve"),
) )
/** Default background for new chats / fresh installs. */
val DEFAULT: Backdrop = ALL.first { it.id == "pexels-yunszyveli-12368637" }
/** True when [path] references a bundled backdrop. */ /** True when [path] references a bundled backdrop. */
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX) fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
@@ -35,8 +35,6 @@ object IrisColors {
val textDim = Color(0xFF8A93A6) val textDim = Color(0xFF8A93A6)
// Bubbles // Bubbles
val bubbleUser = primary
val bubbleAssistant = Color(0xFF2A2E3B)
val bubbleCommentary = Color(0xFF23262F) val bubbleCommentary = Color(0xFF23262F)
// Panels, chips, rows // Panels, chips, rows
@@ -47,7 +45,6 @@ object IrisColors {
val divider = Color(0xFF2A2E3B) val divider = Color(0xFF2A2E3B)
// Status // Status
val statusGrey = Color(0xFF9E9E9E)
val statusAmber = Color(0xFFFFC107) val statusAmber = Color(0xFFFFC107)
val statusGreen = Color(0xFF4CAF50) val statusGreen = Color(0xFF4CAF50)
val statusRed = Color(0xFFF44336) val statusRed = Color(0xFFF44336)
@@ -11,9 +11,3 @@ expect fun localDayKey(epochMillis: Long): String
/** Current wall-clock time in epoch milliseconds (for locally generated items). */ /** Current wall-clock time in epoch milliseconds (for locally generated items). */
expect fun nowMillis(): Long expect fun nowMillis(): Long
/** Host part of a pairing URL (the "host:port" of the full gateway ws URL). */
fun hostFromUrl(url: String): String {
val noScheme = url.trim().substringAfter("://")
return noScheme.substringBefore("/").ifBlank { url.trim() }
}
@@ -5,7 +5,7 @@
CREATE TABLE message ( CREATE TABLE message (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- message id (server id, or local_/sys_ for optimistic) id TEXT NOT NULL, -- message id (server id, or local<rand>/sys<rand> for optimistic)
ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed) ts INTEGER NOT NULL, -- epoch millis (0 for optimistic, not yet echoed)
payload TEXT NOT NULL, -- serialized MessageItem payload TEXT NOT NULL, -- serialized MessageItem
PRIMARY KEY (lane, id) PRIMARY KEY (lane, id)
@@ -13,7 +13,7 @@ CREATE TABLE message (
CREATE TABLE tool ( CREATE TABLE tool (
lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId lane TEXT NOT NULL, -- lane key: chatId or chatId::threadId
id TEXT NOT NULL, -- local tool card id (tool_N) id TEXT NOT NULL, -- local tool card id (tool<rand>)
seq INTEGER NOT NULL, -- card order within the lane (lane position) seq INTEGER NOT NULL, -- card order within the lane (lane position)
payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id) payload TEXT NOT NULL, -- serialized ToolItem (carries its anchor_id)
PRIMARY KEY (lane, id) PRIMARY KEY (lane, id)
@@ -1,6 +1,7 @@
package iris.platform package iris.platform
import iris.state.IrisController import iris.state.IrisController
import java.util.concurrent.CopyOnWriteArrayList
/** /**
* M6: bridge between the desktop shell (tray / window) and the shared * M6: bridge between the desktop shell (tray / window) and the shared
@@ -26,7 +27,10 @@ object DesktopBridge {
@Volatile @Volatile
var controller: IrisController? = null var controller: IrisController? = null
private val notificationListeners = mutableListOf<NotificationListener>() // M-13: CopyOnWriteArrayList — listeners are added from the UI thread and
// iterated from the OkHttp callback thread; a plain mutableListOf's
// .toList() copy is not atomic with a concurrent add.
private val notificationListeners = CopyOnWriteArrayList<NotificationListener>()
fun onNotification(listener: NotificationListener) { fun onNotification(listener: NotificationListener) {
notificationListeners.add(listener) notificationListeners.add(listener)
@@ -38,6 +42,6 @@ object DesktopBridge {
body: String, body: String,
threadId: String?, threadId: String?,
) { ) {
notificationListeners.toList().forEach { it(chatId, title, body, threadId) } notificationListeners.forEach { it(chatId, title, body, threadId) }
} }
} }
@@ -1,7 +1,6 @@
package iris.platform package iris.platform
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
@@ -35,7 +34,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray import kotlinx.serialization.json.buildJsonArray
@@ -62,7 +60,6 @@ import kotlin.random.Random
*/ */
@Composable @Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) { actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column( Column(
modifier = modifier =
Modifier Modifier
@@ -71,16 +68,10 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
) { ) {
Text("Attach a file:", fontSize = 13.sp) Text("Attach a file:", fontSize = 13.sp)
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Button( // pickFile() blocks the UI thread (JFileChooser is modal), so there's
onClick = { // no in-flight state to show while the dialog is open.
busy = true Button(onClick = { onPicked(pickFile()) }) {
val picked = pickFile() Text("Choose file…")
busy = false
onPicked(picked)
},
enabled = !busy,
) {
Text(if (busy) "Choosing…" else "Choose file…")
} }
} }
} }
@@ -138,7 +129,8 @@ private fun guessMime(name: String): String {
"mov" -> "video/quicktime" "mov" -> "video/quicktime"
"mkv" -> "video/x-matroska" "mkv" -> "video/x-matroska"
"mp3" -> "audio/mpeg" "mp3" -> "audio/mpeg"
"m4a", "aac" -> "audio/mp4" "m4a" -> "audio/mp4"
"aac" -> "audio/aac"
"ogg", "opus" -> "audio/ogg" "ogg", "opus" -> "audio/ogg"
"wav" -> "audio/wav" "wav" -> "audio/wav"
"flac" -> "audio/flac" "flac" -> "audio/flac"
@@ -59,7 +59,7 @@ object DesktopNotifier {
"\$n = New-Object System.Windows.Forms.NotifyIcon; " + "\$n = New-Object System.Windows.Forms.NotifyIcon; " +
"\$n.Icon = [System.Drawing.SystemIcons]::Information; " + "\$n.Icon = [System.Drawing.SystemIcons]::Information; " +
"\$n.Visible = \$true; " + "\$n.Visible = \$true; " +
"\$n.ShowBalloonTip(4000, \"${esc(title)}\", \"${esc(body)}\", " + "\$n.ShowBalloonTip(4000, \"${psEsc(title)}\", \"${psEsc(body)}\", " +
"[System.Windows.Forms.ToolTipIcon]::Info); " + "[System.Windows.Forms.ToolTipIcon]::Info); " +
"Start-Sleep -Milliseconds 4500; \$n.Dispose()", "Start-Sleep -Milliseconds 4500; \$n.Dispose()",
) )
@@ -71,4 +71,16 @@ object DesktopNotifier {
} }
private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ") private fun esc(s: String): String = s.replace("\\", "\\\\").replace("\"", "\\\"").replace("\n", " ")
// M-16: PowerShell uses backtick escaping (not backslash) and treats `$`
// as a variable reference, so a body containing `$foo` would be mangled or
// error. Escape backtick first (so the backticks we add aren't doubled),
// then `$` and `"`. Newlines collapse to spaces (balloon tips are single
// line).
private fun psEsc(s: String): String =
s
.replace("`", "``")
.replace("$", "`$")
.replace("\"", "`\"")
.replace("\n", " ")
} }
@@ -29,6 +29,12 @@ class DesktopSecureStore : SecureStore {
private val legacyFile = File(baseDir, "pairing.json") private val legacyFile = File(baseDir, "pairing.json")
private val secret = SecretBackend(baseDir) private val secret = SecretBackend(baseDir)
// M-8: cache the parsed settings so hot-path getters (serverUrl/token per
// connect attempt) don't re-read + re-parse the file on every access.
// Invalidated on every save(). DesktopSecureStore is a per-process
// singleton (created once in Main.kt), so a per-instance cache is safe.
private var cached: Settings? = null
@Serializable @Serializable
private data class Settings( private data class Settings(
val serverUrl: String = "", val serverUrl: String = "",
@@ -77,24 +83,32 @@ class DesktopSecureStore : SecureStore {
), ),
) )
if (legacy.token.isNotBlank()) secret.write(legacy.token) if (legacy.token.isNotBlank()) secret.write(legacy.token)
// L-49: only delete the legacy file after a successful migration;
// a parse failure (legacy == null) must not destroy the data.
legacyFile.delete()
} }
legacyFile.delete()
} }
private fun load(): Settings = private fun load(): Settings {
if (settingsFile.exists()) { cached?.let { return it }
try { val s =
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText()) if (settingsFile.exists()) {
} catch (_: Exception) { try {
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
} catch (_: Exception) {
Settings()
}
} else {
Settings() Settings()
} }
} else { cached = s
Settings() return s
} }
private fun save(data: Settings) { private fun save(data: Settings) {
baseDir.mkdirs() baseDir.mkdirs()
settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data)) settingsFile.writeText(IrisJson.instance.encodeToString(Settings.serializer(), data))
cached = data
} }
override var serverUrl: String override var serverUrl: String
@@ -206,7 +220,7 @@ class DesktopSecureStore : SecureStore {
} }
override var backgroundMode: String override var backgroundMode: String
get() = load().backgroundMode.ifBlank { "color" } get() = load().backgroundMode.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) { set(value) {
val d = load() val d = load()
save(d.copy(backgroundMode = value)) save(d.copy(backgroundMode = value))
@@ -257,8 +271,22 @@ class DesktopSecureStore : SecureStore {
} }
override fun clear() { override fun clear() {
// M-10: clearing pairing must also wipe the device identity + push
// state, otherwise a re-pair to a different gateway would keep the old
// deviceId/syncCursor/ntfyTopic and the server would treat the new
// pairing as the same device.
val d = load() val d = load()
save(d.copy(serverUrl = "")) save(
d.copy(
serverUrl = "",
deviceId = "",
syncCursor = 0L,
fcmToken = "",
ntfyTopic = "",
ntfyServer = "",
pushBackend = "",
),
)
secret.clear() secret.clear()
} }
} }
@@ -291,7 +319,12 @@ private class SecretBackend(
fun write(value: String) { fun write(value: String) {
if (keyring != null) { if (keyring != null) {
keyring.write(value) keyring.write(value)
if (keyring.read() == value) return if (keyring.read() == value) {
// L-50: the keyring now holds the secret; drop the stale
// encrypted file so the old token can't be read back.
encFile.delete()
return
}
// Keyring accepted the write but did not persist it (e.g. KWallet // Keyring accepted the write but did not persist it (e.g. KWallet
// without a live daemon). Drop the stale entry and fall back to // without a live daemon). Drop the stale entry and fall back to
// the encrypted file so the token survives a restart. // the encrypted file so the token survives a restart.
@@ -384,28 +417,28 @@ private class KeyringBackend {
fun write(value: String) { fun write(value: String) {
try { try {
if (isMac) { // M-9: pass the secret on stdin, not as a CLI argument. A trailing
ProcessBuilder( // argument is visible in the process list (`ps`); `secret-tool
"security", // store` and `security add-generic-password -w` both read the
"add-generic-password", // secret from stdin when no value argument is given.
"-U", val cmd =
"-a", if (isMac) {
"iris", listOf(
"-s", "security",
"iris-gateway-token", "add-generic-password",
"-w", "-U",
value, "-a",
).inheritIO().start().waitFor() "iris",
} else { "-s",
ProcessBuilder( "iris-gateway-token",
"secret-tool", "-w",
"store", )
"--label=Iris gateway token", } else {
"app", listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
"iris", }
"token", ProcessBuilder(cmd).start().apply {
value, outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
).inheritIO().start().waitFor() waitFor()
} }
} catch (_: Exception) { } catch (_: Exception) {
} }
@@ -1,16 +0,0 @@
package iris.media
import java.io.File
actual class FileSource actual constructor(path: String) : AutoCloseable {
private val file = File(path)
private val input = file.inputStream()
actual fun size(): Long = file.length()
actual fun read(buf: ByteArray): Int = input.read(buf)
override fun close() {
input.close()
}
}
@@ -5,22 +5,44 @@ import java.io.FileOutputStream
actual interface MediaWriter : AutoCloseable { actual interface MediaWriter : AutoCloseable {
actual val path: String actual val path: String
actual fun write(bytes: ByteArray) actual fun write(bytes: ByteArray)
} }
actual class MediaCache actual constructor(baseDir: String) { actual class MediaCache actual constructor(
baseDir: String,
) {
private val maxBytes: Long = 500L * 1024 * 1024 private val maxBytes: Long = 500L * 1024 * 1024
private val mediaDir: File = File(baseDir, "media").apply { mkdirs() } private val mediaDir: File = File(baseDir, "media").apply { mkdirs() }
actual fun path(mediaId: String, mime: String): String? { /**
val f = File(mediaDir, "$mediaId${extForMime(mime)}") * Resolve the cache file for [mediaId]. The id is server-controlled
* (`media.offer`); reject path-traversal values so a hostile gateway can't
* write outside [mediaDir] (M-2 / S-1). Returns null when the id is
* invalid.
*/
private fun fileFor(
mediaId: String,
mime: String,
): File? = if (!isValidMediaId(mediaId)) null else File(mediaDir, "$mediaId${extForMime(mime)}")
actual fun path(
mediaId: String,
mime: String,
): String? {
val f = fileFor(mediaId, mime) ?: return null
return if (f.exists()) f.absolutePath else null return if (f.exists()) f.absolutePath else null
} }
actual fun openWriter(mediaId: String, mime: String): MediaWriter = actual fun openWriter(
JvmMediaWriter(File(mediaDir, "$mediaId${extForMime(mime)}"), this) mediaId: String,
mime: String,
): MediaWriter = JvmMediaWriter(fileFor(mediaId, mime) ?: throw IllegalArgumentException("invalid media id"), this)
actual fun remove(mediaId: String, mime: String) { actual fun remove(
mediaId: String,
mime: String,
) {
path(mediaId, mime)?.let { File(it).delete() } path(mediaId, mime)?.let { File(it).delete() }
} }
@@ -36,7 +58,10 @@ actual class MediaCache actual constructor(baseDir: String) {
} }
} }
private class JvmMediaWriter(private val target: File, private val cache: MediaCache) : MediaWriter { private class JvmMediaWriter(
private val target: File,
private val cache: MediaCache,
) : MediaWriter {
private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part")) private val out: FileOutputStream = FileOutputStream(File(target.parentFile, "${target.name}.part"))
override val path: String get() = target.absolutePath override val path: String get() = target.absolutePath