fix(app): apply whole-review fixes (HIGH/MEDIUM/LOW) + dead code & stale comments
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:
1 parent
a4e4a4ea63
commit
863ab34915
32 files changed
+610
-280
No files matched your search
@@ -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"
|
||||||
|
|||||||
@@ -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 ->
|
||||||
|
val c =
|
||||||
|
object : OnBackPressedCallback(true) {
|
||||||
override fun handleOnBackPressed() {
|
override fun handleOnBackPressed() {
|
||||||
currentOnBack.value()
|
currentOnBack.value()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
dispatcher.addCallback(callback)
|
d.addCallback(c)
|
||||||
onDispose { callback.remove() }
|
c
|
||||||
}
|
}
|
||||||
onDispose { }
|
onDispose { callback?.remove() }
|
||||||
}
|
}
|
||||||
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,14 +49,18 @@ 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.
|
||||||
|
val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffffff)
|
||||||
|
val intent =
|
||||||
|
Intent(ACTION_OPEN_CHAT).apply {
|
||||||
setPackage(context.packageName)
|
setPackage(context.packageName)
|
||||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
||||||
putExtra("chat_id", chatId)
|
putExtra("chat_id", chatId)
|
||||||
if (threadId != null) putExtra("thread_id", threadId)
|
if (threadId != null) putExtra("thread_id", threadId)
|
||||||
}
|
}
|
||||||
val pi = PendingIntent.getActivity(
|
val pi =
|
||||||
|
PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
id,
|
id,
|
||||||
intent,
|
intent,
|
||||||
@@ -59,7 +69,8 @@ object IrisNotifications {
|
|||||||
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
|
||||||
|
.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()
|
.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,24 +75,56 @@ 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 =
|
||||||
|
Request
|
||||||
|
.Builder()
|
||||||
.url(url)
|
.url(url)
|
||||||
.header("Accept", "text/event-stream")
|
.header("Accept", "text/event-stream")
|
||||||
.build()
|
.build()
|
||||||
|
var backoff = 1_000L
|
||||||
|
while (true) {
|
||||||
try {
|
try {
|
||||||
client.newCall(request).execute().use { resp ->
|
client.newCall(request).execute().use { resp ->
|
||||||
if (!resp.isSuccessful) return
|
if (!resp.isSuccessful) {
|
||||||
|
IrisLog.w("ntfy stream HTTP ${resp.code}")
|
||||||
|
} else {
|
||||||
val body = resp.body ?: return
|
val body = resp.body ?: return
|
||||||
val source = body.source()
|
readEvents(body.source())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
IrisLog.w("ntfy stream dropped: ${e.message}")
|
||||||
|
}
|
||||||
|
// Stream ended (EOF, error, or non-2xx): back off and reconnect.
|
||||||
|
delay(backoff)
|
||||||
|
backoff = (backoff * 2).coerceAtMost(30_000L)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read ntfy SSE events until EOF. `readUtf8Line()` returns null at EOF;
|
||||||
|
* do NOT use `source.exhausted()` here — it reads until EOF and would
|
||||||
|
* block forever on a live stream.
|
||||||
|
*/
|
||||||
|
private fun readEvents(source: okio.BufferedSource) {
|
||||||
var data: String? = null
|
var data: String? = null
|
||||||
var title: String? = null
|
var title: String? = null
|
||||||
var msgBody: String? = null
|
var msgBody: String? = null
|
||||||
while (!source.exhausted()) {
|
while (true) {
|
||||||
val line = source.readUtf8Line() ?: break
|
val line = source.readUtf8Line() ?: break
|
||||||
when {
|
when {
|
||||||
line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
|
line.startsWith("X-Data:") -> {
|
||||||
line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
|
data = line.removePrefix("X-Data:").trim()
|
||||||
line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
|
}
|
||||||
|
|
||||||
|
line.startsWith("X-Title:") -> {
|
||||||
|
title = line.removePrefix("X-Title:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
line.startsWith("data:") -> {
|
||||||
|
msgBody = line.removePrefix("data:").trim()
|
||||||
|
}
|
||||||
|
|
||||||
line.isEmpty() -> {
|
line.isEmpty() -> {
|
||||||
// Event boundary: process the accumulated message.
|
// Event boundary: process the accumulated message.
|
||||||
data?.let { handleData(it, title, msgBody) }
|
data?.let { handleData(it, title, msgBody) }
|
||||||
@@ -88,14 +135,14 @@ class NtfyListenerService : Service() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_: Exception) {
|
|
||||||
// Stream dropped; the service is START_STICKY so the system
|
|
||||||
// restarts it. If it keeps failing, the WS path still works.
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun handleData(dataJson: String, title: String?, msgBody: String?) {
|
private fun handleData(
|
||||||
val data = try {
|
dataJson: String,
|
||||||
|
title: String?,
|
||||||
|
msgBody: String?,
|
||||||
|
) {
|
||||||
|
val data =
|
||||||
|
try {
|
||||||
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
IrisJson.instance.decodeFromString<JsonObject>(dataJson)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
null
|
null
|
||||||
@@ -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,10 +204,27 @@ class ChatStore {
|
|||||||
lane: String,
|
lane: String,
|
||||||
transform: (List<ChatItem>) -> List<ChatItem>,
|
transform: (List<ChatItem>) -> List<ChatItem>,
|
||||||
) {
|
) {
|
||||||
|
synchronized(lock) {
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
map[lane] = transform(map[lane].orEmpty())
|
map[lane] = transform(map[lane].orEmpty())
|
||||||
_lanes.value = map
|
_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()
|
||||||
|
var changed = false
|
||||||
|
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,6 +378,7 @@ 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
|
||||||
|
synchronized(lock) {
|
||||||
val map = _lanes.value.toMutableMap()
|
val map = _lanes.value.toMutableMap()
|
||||||
val flatList = map[flatLane].orEmpty()
|
val flatList = map[flatLane].orEmpty()
|
||||||
val idx =
|
val idx =
|
||||||
@@ -361,10 +386,11 @@ class ChatStore {
|
|||||||
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
it is MessageItem && it.role == ROLE_USER && it.text == p.text &&
|
||||||
(it.pending || it.status == MsgStatus.Failed)
|
(it.pending || it.status == MsgStatus.Failed)
|
||||||
}
|
}
|
||||||
if (idx < 0) return
|
if (idx < 0) return@synchronized
|
||||||
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
map[flatLane] = flatList.toMutableList().also { it.removeAt(idx) }
|
||||||
_lanes.value = map
|
_lanes.value = map
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/** Merge server media refs into existing items, keeping local paths. */
|
/** Merge server media refs into existing items, keeping local paths. */
|
||||||
private fun mergeMedia(
|
private fun mergeMedia(
|
||||||
@@ -540,10 +566,12 @@ class ChatStore {
|
|||||||
frame: Frame,
|
frame: Frame,
|
||||||
) {
|
) {
|
||||||
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
|
val p = frame.payloadAs<TodoUpdatePayload>() ?: return
|
||||||
|
synchronized(lock) {
|
||||||
val map = _todos.value.toMutableMap()
|
val map = _todos.value.toMutableMap()
|
||||||
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
|
if (p.todos.isEmpty()) map.remove(lane) else map[lane] = p.todos
|
||||||
_todos.value = map
|
_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,38 +713,36 @@ 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) {
|
||||||
|
synchronized(lock) {
|
||||||
val map = _unread.value.toMutableMap()
|
val map = _unread.value.toMutableMap()
|
||||||
map[lane] = (map[lane] ?: 0) + 1
|
map[lane] = (map[lane] ?: 0) + 1
|
||||||
_unread.value = map
|
_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) {
|
||||||
|
synchronized(lock) {
|
||||||
val map = _unread.value.toMutableMap()
|
val map = _unread.value.toMutableMap()
|
||||||
if (map.remove(lane) != null) _unread.value = map
|
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). */
|
||||||
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
|
fun unreadFor(lane: String): Int = _unread.value[lane] ?: 0
|
||||||
|
|
||||||
/** 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,17 +783,10 @@ 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
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Finalize in-flight items after the gateway goes away (restart / network
|
* Finalize in-flight items after the gateway goes away (restart / network
|
||||||
@@ -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() {
|
||||||
|
synchronized(lock) {
|
||||||
_lanes.value = emptyMap()
|
_lanes.value = emptyMap()
|
||||||
_unread.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,15 +6,17 @@ 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 =
|
||||||
|
when {
|
||||||
mime.startsWith("image/") -> KIND_IMAGE
|
mime.startsWith("image/") -> KIND_IMAGE
|
||||||
mime.startsWith("video/") -> KIND_VIDEO
|
mime.startsWith("video/") -> KIND_VIDEO
|
||||||
mime.startsWith("audio/") -> KIND_AUDIO
|
mime.startsWith("audio/") -> KIND_AUDIO
|
||||||
else -> KIND_DOCUMENT
|
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 =
|
||||||
|
when {
|
||||||
mime == "image/jpeg" -> ".jpg"
|
mime == "image/jpeg" -> ".jpg"
|
||||||
mime == "image/png" -> ".png"
|
mime == "image/png" -> ".png"
|
||||||
mime == "image/webp" -> ".webp"
|
mime == "image/webp" -> ".webp"
|
||||||
@@ -34,4 +36,13 @@ fun extForMime(mime: String): String = when {
|
|||||||
mime == "application/zip" -> ".zip"
|
mime == "application/zip" -> ".zip"
|
||||||
mime == "text/plain" -> ".txt"
|
mime == "text/plain" -> ".txt"
|
||||||
else -> ".bin"
|
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,12 +272,15 @@ 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
|
||||||
|
* OkHttp clients). */
|
||||||
|
private fun httpGateway(): HttpGateway? =
|
||||||
|
synchronized(this) {
|
||||||
val url = store.serverUrl.trim()
|
val url = store.serverUrl.trim()
|
||||||
val token = store.token
|
val token = store.token
|
||||||
if (url.isBlank() || token.isBlank()) return null
|
if (url.isBlank() || token.isBlank()) return@synchronized null
|
||||||
return http
|
http
|
||||||
?: HttpGateway(
|
?: HttpGateway(
|
||||||
client,
|
client,
|
||||||
HttpGateway.deriveHttpUrl(url),
|
HttpGateway.deriveHttpUrl(url),
|
||||||
@@ -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,11 +83,15 @@ 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 {
|
||||||
|
cached?.let { return it }
|
||||||
|
val s =
|
||||||
if (settingsFile.exists()) {
|
if (settingsFile.exists()) {
|
||||||
try {
|
try {
|
||||||
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
IrisJson.instance.decodeFromString(Settings.serializer(), settingsFile.readText())
|
||||||
@@ -91,10 +101,14 @@ class DesktopSecureStore : SecureStore {
|
|||||||
} else {
|
} else {
|
||||||
Settings()
|
Settings()
|
||||||
}
|
}
|
||||||
|
cached = s
|
||||||
|
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,8 +417,13 @@ private class KeyringBackend {
|
|||||||
|
|
||||||
fun write(value: String) {
|
fun write(value: String) {
|
||||||
try {
|
try {
|
||||||
|
// M-9: pass the secret on stdin, not as a CLI argument. A trailing
|
||||||
|
// argument is visible in the process list (`ps`); `secret-tool
|
||||||
|
// store` and `security add-generic-password -w` both read the
|
||||||
|
// secret from stdin when no value argument is given.
|
||||||
|
val cmd =
|
||||||
if (isMac) {
|
if (isMac) {
|
||||||
ProcessBuilder(
|
listOf(
|
||||||
"security",
|
"security",
|
||||||
"add-generic-password",
|
"add-generic-password",
|
||||||
"-U",
|
"-U",
|
||||||
@@ -394,18 +432,13 @@ private class KeyringBackend {
|
|||||||
"-s",
|
"-s",
|
||||||
"iris-gateway-token",
|
"iris-gateway-token",
|
||||||
"-w",
|
"-w",
|
||||||
value,
|
)
|
||||||
).inheritIO().start().waitFor()
|
|
||||||
} else {
|
} else {
|
||||||
ProcessBuilder(
|
listOf("secret-tool", "store", "--label=Iris gateway token", "app", "iris")
|
||||||
"secret-tool",
|
}
|
||||||
"store",
|
ProcessBuilder(cmd).start().apply {
|
||||||
"--label=Iris gateway token",
|
outputStream.use { it.write(value.toByteArray(Charsets.UTF_8)) }
|
||||||
"app",
|
waitFor()
|
||||||
"iris",
|
|
||||||
"token",
|
|
||||||
value,
|
|
||||||
).inheritIO().start().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
|
||||||
|
|||||||
Reference in new issue
Block a user