diff --git a/AGENTS.md b/AGENTS.md
index 81c3b86..4dc9710 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -25,12 +25,12 @@
## 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.
- `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.
-- JDK 17; no system Gradle — always the wrapper (`./gradlew`).
-- Desktop jpackage on Linux/JDK 17 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
+- **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 21 prints a non-fatal `pure virtual method called` (JDK-8348560); the app works.
## Testing quirks
diff --git a/app/androidApp/src/main/AndroidManifest.xml b/app/androidApp/src/main/AndroidManifest.xml
index 48deb5f..a5779e9 100644
--- a/app/androidApp/src/main/AndroidManifest.xml
+++ b/app/androidApp/src/main/AndroidManifest.xml
@@ -12,6 +12,12 @@
+
org.gradle.java.home=/path/to/jdk21
+# - or export JAVA_HOME=/path/to/jdk21 before running ./gradlew
org.gradle.caching=true
org.gradle.configuration-cache=true
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt
index 36de7d4..9aabbd6 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidPush.kt
@@ -1,6 +1,7 @@
package iris.platform
import android.Manifest
+import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import androidx.core.content.ContextCompat
@@ -15,7 +16,9 @@ actual fun setActiveController(controller: Any?) {
actual fun syncNtfyListener(backend: String) {
val context = AndroidEnv.context
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()) {
ContextCompat.startForegroundService(context, intent)
} 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(
chatId: String?,
chatName: String?,
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt
index f565c03..60ea2d2 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidSecureStore.kt
@@ -179,10 +179,20 @@ class AndroidSecureStore(
}
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
.edit()
.remove(KEY_URL)
.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()
}
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt b/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt
index 7fc3ba2..c62a9c6 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/AndroidWebView.kt
@@ -22,7 +22,10 @@ import com.multiplatform.webview.web.rememberWebViewStateWithHTMLData
private const val ARTIFACT_BASE_URL = "https://iris-artifact.local/"
@Composable
-actual fun PlatformWebView(html: String, modifier: Modifier) {
+actual fun PlatformWebView(
+ html: String,
+ modifier: Modifier,
+) {
val state = rememberWebViewStateWithHTMLData(data = html, baseUrl = ARTIFACT_BASE_URL)
state.webSettings.androidWebSettings.domStorageEnabled = true
WebView(state, modifier = modifier)
@@ -33,16 +36,18 @@ actual fun Modifier.handleSystemBack(onBack: () -> Unit): Modifier {
val dispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
val currentOnBack = rememberUpdatedState(onBack)
DisposableEffect(dispatcher) {
- if (dispatcher != null) {
- val callback = object : OnBackPressedCallback(true) {
- override fun handleOnBackPressed() {
- currentOnBack.value()
- }
+ val callback =
+ dispatcher?.let { d ->
+ val c =
+ object : OnBackPressedCallback(true) {
+ override fun handleOnBackPressed() {
+ currentOnBack.value()
+ }
+ }
+ d.addCallback(c)
+ c
}
- dispatcher.addCallback(callback)
- onDispose { callback.remove() }
- }
- onDispose { }
+ onDispose { callback?.remove() }
}
return this
-}
\ No newline at end of file
+}
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt b/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt
index 7278c99..15eee89 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/IrisFirebaseMessagingService.kt
@@ -11,9 +11,9 @@ import iris.net.GatewayClient
*
* - [onNewToken]: persist the rotated token and push it to the server via
* `fcm.register` (so the next push targets the current token).
- * - [onMessageReceived]: the data payload drives a silent sync. When the app
- * is foregrounded the SSE path already delivered the frame (in-app banner),
- * so we only post a system notification when backgrounded.
+ * - [onMessageReceived]: posts a system notification from the data payload.
+ * When the app is foregrounded the SSE path already delivered the frame
+ * (in-app banner), so we only post a notification when backgrounded.
*
* Inert without a Firebase project (no google-services.json): the service is
* declared in the manifest but never receives messages, and the app falls
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/IrisNotifications.kt b/app/shared/src/androidMain/kotlin/iris/platform/IrisNotifications.kt
index d9d085b..1d15d5f 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/IrisNotifications.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/IrisNotifications.kt
@@ -5,7 +5,6 @@ import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
-import android.os.Build
import androidx.core.app.NotificationCompat
/**
@@ -21,15 +20,22 @@ object IrisNotifications {
const val ACTION_OPEN_CHAT = "dev.iris.app.OPEN_CHAT"
private const val NOTIF_ID_BASE = 1_000_000
- fun ensureChannel(context: Context, chatId: String, chatName: String? = null) {
- if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
+ // L-31: notification channel ids are capped at 64 chars (Android limit) and
+ // 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 id = CHANNEL_PREFIX + chatId
+ val id = channelIdFor(chatId)
val name = chatName ?: chatId
if (nm.getNotificationChannel(id) == null) {
nm.createNotificationChannel(
NotificationChannel(id, name, NotificationManager.IMPORTANCE_DEFAULT)
- .apply { description = "Iris messages for $name" }
+ .apply { description = "Iris messages for $name" },
)
}
}
@@ -43,23 +49,28 @@ object IrisNotifications {
threadId: String?,
) {
ensureChannel(context, chatId, chatName)
- val id = NOTIF_ID_BASE + (chatId.hashCode() and 0xffff)
- val intent = Intent(ACTION_OPEN_CHAT).apply {
- setPackage(context.packageName)
- flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
- putExtra("chat_id", chatId)
- if (threadId != null) putExtra("thread_id", threadId)
- }
- val pi = PendingIntent.getActivity(
- context,
- id,
- intent,
- PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
- )
+ // L-32: 24-bit hash (was 16-bit) to reduce the chance two chatIds map
+ // 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)
+ flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
+ putExtra("chat_id", chatId)
+ if (threadId != null) putExtra("thread_id", threadId)
+ }
+ val pi =
+ PendingIntent.getActivity(
+ context,
+ id,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
+ )
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
nm.notify(
id,
- NotificationCompat.Builder(context, CHANNEL_PREFIX + chatId)
+ NotificationCompat
+ .Builder(context, channelIdFor(chatId))
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle(title)
.setContentText(body)
@@ -69,4 +80,4 @@ object IrisNotifications {
.build(),
)
}
-}
\ No newline at end of file
+}
diff --git a/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt b/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt
index b0f3160..d9dd507 100644
--- a/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt
+++ b/app/shared/src/androidMain/kotlin/iris/platform/NtfyListenerService.kt
@@ -11,11 +11,13 @@ import android.os.IBinder
import androidx.core.app.NotificationCompat
import androidx.core.content.ContextCompat
import iris.protocol.IrisJson
+import iris.util.IrisLog
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.serialization.json.JsonObject
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 system notification for each push. The structured payload rides in the
- * `X-Data` header (JSON: chat_id, kind, cursor, thread_id); the message body
- * is the short preview. When the app is foregrounded the WS path already
- * delivered the frame, so the service skips posting to avoid a duplicate.
+ * `X-Data` SSE field (JSON: chat_id, kind, cursor, thread_id); the message
+ * body is the short preview. When the app is foregrounded the SSE path
+ * 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() {
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private var streamJob: Job? = null
- private val client = OkHttpClient.Builder()
- .readTimeout(0, TimeUnit.MILLISECONDS) // long-lived stream
- .build()
+ private val client =
+ 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()
- override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
+ override fun onStartCommand(
+ intent: Intent?,
+ flags: Int,
+ startId: Int,
+ ): Int {
startForeground(NOTIF_ID, foregroundNotification())
streamJob?.cancel()
streamJob = scope.launch { stream() }
@@ -60,46 +75,78 @@ class NtfyListenerService : Service() {
if (topic.isBlank()) return
val server = store.ntfyServer.ifBlank { DEFAULT_NTFY_SERVER }.removeSuffix("/")
val url = "$server/$topic"
- val request = Request.Builder()
- .url(url)
- .header("Accept", "text/event-stream")
- .build()
- try {
- client.newCall(request).execute().use { resp ->
- if (!resp.isSuccessful) return
- val body = resp.body ?: return
- val source = body.source()
- var data: String? = null
- var title: String? = null
- var msgBody: String? = null
- while (!source.exhausted()) {
- val line = source.readUtf8Line() ?: break
- when {
- line.startsWith("X-Data:") -> data = line.removePrefix("X-Data:").trim()
- line.startsWith("X-Title:") -> title = line.removePrefix("X-Title:").trim()
- line.startsWith("data:") -> msgBody = line.removePrefix("data:").trim()
- line.isEmpty() -> {
- // Event boundary: process the accumulated message.
- data?.let { handleData(it, title, msgBody) }
- data = null
- title = null
- msgBody = null
- }
+ val request =
+ Request
+ .Builder()
+ .url(url)
+ .header("Accept", "text/event-stream")
+ .build()
+ var backoff = 1_000L
+ while (true) {
+ try {
+ client.newCall(request).execute().use { resp ->
+ if (!resp.isSuccessful) {
+ IrisLog.w("ntfy stream HTTP ${resp.code}")
+ } else {
+ val body = resp.body ?: return
+ readEvents(body.source())
}
}
+ } catch (e: Exception) {
+ IrisLog.w("ntfy stream dropped: ${e.message}")
}
- } catch (_: Exception) {
- // Stream dropped; the service is START_STICKY so the system
- // restarts it. If it keeps failing, the WS path still works.
+ // Stream ended (EOF, error, or non-2xx): back off and reconnect.
+ delay(backoff)
+ backoff = (backoff * 2).coerceAtMost(30_000L)
}
}
- private fun handleData(dataJson: String, title: String?, msgBody: String?) {
- val data = try {
- IrisJson.instance.decodeFromString(dataJson)
- } catch (_: Exception) {
- null
+ /**
+ * 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 title: String? = null
+ var msgBody: String? = null
+ while (true) {
+ val line = source.readUtf8Line() ?: break
+ when {
+ line.startsWith("X-Data:") -> {
+ data = line.removePrefix("X-Data:").trim()
+ }
+
+ line.startsWith("X-Title:") -> {
+ title = line.removePrefix("X-Title:").trim()
+ }
+
+ line.startsWith("data:") -> {
+ msgBody = line.removePrefix("data:").trim()
+ }
+
+ line.isEmpty() -> {
+ // Event boundary: process the accumulated message.
+ data?.let { handleData(it, title, msgBody) }
+ data = null
+ title = null
+ msgBody = null
+ }
+ }
}
+ }
+
+ private fun handleData(
+ dataJson: String,
+ title: String?,
+ msgBody: String?,
+ ) {
+ val data =
+ try {
+ IrisJson.instance.decodeFromString(dataJson)
+ } catch (_: Exception) {
+ null
+ }
val chatId = data?.str("chat_id") ?: "default"
val threadId = data?.str("thread_id")
// The short preview rides in the SSE `data:` field; fall back to the
@@ -126,11 +173,12 @@ class NtfyListenerService : Service() {
LISTENER_CHANNEL,
"Iris push listener",
NotificationManager.IMPORTANCE_MIN,
- )
+ ),
)
}
}
- return NotificationCompat.Builder(context, LISTENER_CHANNEL)
+ return NotificationCompat
+ .Builder(context, LISTENER_CHANNEL)
.setSmallIcon(android.R.drawable.ic_dialog_info)
.setContentTitle("Iris")
.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). */
-private fun JsonObject?.str(key: String): String? =
- (this?.get(key) as? JsonPrimitive)?.content
\ No newline at end of file
+private fun JsonObject?.str(key: String): String? = (this?.get(key) as? JsonPrimitive)?.content
diff --git a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt
index 9585b6d..71def05 100644
--- a/app/shared/src/commonMain/kotlin/iris/IrisApp.kt
+++ b/app/shared/src/commonMain/kotlin/iris/IrisApp.kt
@@ -32,9 +32,9 @@ import iris.util.PairLink
/**
* Root composable shared by the Android and Desktop shells.
*
- * M1: routes between the Connect screen (unpaired / auth failed) and the
- * Chat screen (paired). Later milestones add the channel list, search,
- * settings, and media (docs/10-android-app.md).
+ * Routes between the Connect screen (unpaired / auth failed) and the main
+ * app (paired), which hosts the channel list, chat, search, settings, and
+ * media (docs/10-android-app.md).
*/
@Composable
fun IrisApp(
diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt
index db15273..13e1414 100644
--- a/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt
+++ b/app/shared/src/commonMain/kotlin/iris/data/ChannelStore.kt
@@ -71,6 +71,54 @@ class ChannelStore {
_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): List =
list.sortedWith(
compareByDescending { it.isDefault }
diff --git a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt
index 9a43d71..4e151fd 100644
--- a/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt
+++ b/app/shared/src/commonMain/kotlin/iris/data/ChatStore.kt
@@ -156,7 +156,12 @@ class ChatStore {
private val _unread = MutableStateFlow