Added default backdrops to the chat.

This commit is contained in:
ARIA committed 2026-08-21 15:48:30 +02:00
1 parent 96b60daf08
commit 75d491fd30
28 files changed
+1091 -330

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

@@ -40,16 +40,16 @@ import androidx.compose.ui.unit.sp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.core.content.FileProvider
import androidx.core.net.toUri
import androidx.media3.common.MediaItem as M3MediaItem
import androidx.media3.common.Player
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.ui.PlayerView
import iris.data.MediaItem
import iris.media.extForMime
import iris.protocol.KIND_VIDEO
import java.io.File
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import java.io.File
import androidx.media3.common.MediaItem as M3MediaItem
/** App context holder (set in MainActivity.onCreate). */
object AndroidEnv {
@@ -59,7 +59,8 @@ object AndroidEnv {
@Composable
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
@@ -85,7 +86,8 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
val context = LocalContext.current
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
val launcher =
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
if (uri == null) {
onPicked(null)
return@rememberLauncherForActivityResult
@@ -107,7 +109,10 @@ actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = { launcher.launch("image/*") }) { Text("Choose image…") }
}
private fun queryDisplayName(context: Context, uri: Uri): String? {
private fun queryDisplayName(
context: Context,
uri: Uri,
): String? {
context.contentResolver.query(uri, null, null, null, null)?.use { c ->
val idx = c.getColumnIndex(OpenableColumns.DISPLAY_NAME)
if (idx >= 0 && c.moveToFirst()) return c.getString(idx)
@@ -119,15 +124,20 @@ actual fun mediaCacheBaseDir(): String =
AndroidEnv.context.externalCacheDir?.absolutePath
?: AndroidEnv.context.cacheDir.absolutePath
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
BitmapFactory.decodeFile(path)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
// inJustDecodeBounds fills [bounds] and returns null by design — only
// the outWidth/outHeight are meaningful here.
@@ -141,9 +151,40 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
} catch (e: Exception) {
null
}
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
actual suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, bounds)
if (bounds.outWidth <= 0 || bounds.outHeight <= 0) return@withContext null
var sample = 1
while (maxOf(bounds.outWidth, bounds.outHeight) / sample > maxSize) sample *= 2
val opts = BitmapFactory.Options().apply { inSampleSize = sample }
BitmapFactory.decodeByteArray(bytes, 0, bytes.size, opts)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
actual fun readBackdropBytes(id: String): ByteArray? =
try {
AndroidEnv.context.assets
.open("backdrops/$id.jpg")
.use { it.readBytes() }
} catch (e: Exception) {
null
}
actual suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String? =
withContext(Dispatchers.IO) {
try {
val bounds = BitmapFactory.Options().apply { inJustDecodeBounds = true }
BitmapFactory.decodeFile(path, bounds)
@@ -159,19 +200,23 @@ actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = wi
} catch (e: Exception) {
null
}
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
} catch (e: Exception) {
null
}
}
}
@Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath
if (path == null) {
Box(modifier = modifier, contentAlignment = Alignment.Center) {
@@ -191,7 +236,10 @@ actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
}
@Composable
private fun VideoPlayerView(path: String, modifier: Modifier) {
private fun VideoPlayerView(
path: String,
modifier: Modifier,
) {
AndroidView(
factory = { ctx ->
PlayerView(ctx).apply {
@@ -205,16 +253,22 @@ private fun VideoPlayerView(path: String, modifier: Modifier) {
},
update = {},
onRelease = { view -> view.player?.release() },
modifier = modifier
modifier =
modifier
.fillMaxWidth()
.height(220.dp),
)
}
@Composable
private fun AudioPlayerView(path: String, filename: String, modifier: Modifier) {
private fun AudioPlayerView(
path: String,
filename: String,
modifier: Modifier,
) {
val context = LocalContext.current
val player = remember {
val player =
remember {
ExoPlayer.Builder(context).build().apply {
setMediaItem(M3MediaItem.fromUri(path.toUri()))
prepare()
@@ -237,7 +291,8 @@ private fun AudioPlayerView(path: String, filename: String, modifier: Modifier)
}
Row(
modifier = modifier
modifier =
modifier
.fillMaxWidth()
.padding(8.dp),
verticalAlignment = Alignment.CenterVertically,
@@ -270,17 +325,21 @@ private fun fmt(seconds: Float): String {
return "%d:%02d".format(s / 60, s % 60)
}
actual fun openDocument(path: String, mime: String) {
actual fun openDocument(
path: String,
mime: String,
) {
val context = AndroidEnv.context
try {
val file = File(path)
val uri = FileProvider.getUriForFile(context, "${context.packageName}.fileprovider", file)
val intent = Intent(Intent.ACTION_VIEW).apply {
val intent =
Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, mime)
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
context.startActivity(intent)
} catch (e: Exception) {
Toast.makeText(context, "No app to open ${mime}", Toast.LENGTH_SHORT).show()
Toast.makeText(context, "No app to open $mime", Toast.LENGTH_SHORT).show()
}
}
@@ -6,6 +6,8 @@ import android.os.Build
import androidx.security.crypto.EncryptedSharedPreferences
import androidx.security.crypto.MasterKey
import iris.data.SecureStore
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import java.util.UUID
@@ -15,13 +17,17 @@ import java.util.UUID
* values are migrated on first run (read old key, write encrypted, delete
* old key) so an upgrade never loses the pairing.
*/
class AndroidSecureStore(context: Context) : SecureStore {
class AndroidSecureStore(
context: Context,
) : SecureStore {
private val appContext = context.applicationContext
private val plainPrefs = appContext.getSharedPreferences(PLAIN_PREFS_NAME, Context.MODE_PRIVATE)
private val prefs: SharedPreferences = EncryptedSharedPreferences.create(
private val prefs: SharedPreferences =
EncryptedSharedPreferences.create(
appContext,
SECURE_PREFS_NAME,
MasterKey.Builder(appContext)
MasterKey
.Builder(appContext)
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
.build(),
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
@@ -50,7 +56,8 @@ class AndroidSecureStore(context: Context) : SecureStore {
}
if (migrated) editor.apply()
// The plain store must not keep a copy of any value.
plainPrefs.edit()
plainPrefs
.edit()
.remove(KEY_URL)
.remove(KEY_TOKEN)
.remove(KEY_DEVICE_ID)
@@ -73,7 +80,12 @@ class AndroidSecureStore(context: Context) : SecureStore {
get() {
var id = prefs.getString(KEY_DEVICE_ID, null)
if (id.isNullOrBlank()) {
id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
id = "dev_" +
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
prefs.edit().putString(KEY_DEVICE_ID, id).apply()
}
return id
@@ -123,7 +135,11 @@ class AndroidSecureStore(context: Context) : SecureStore {
set(value) = prefs.edit().putInt(KEY_AGENT_BUBBLE_COLOR, value).apply()
override var backgroundMode: String
get() = prefs.getString(KEY_BACKGROUND_MODE, "color").orEmpty().ifBlank { "color" }
get() =
prefs
.getString(KEY_BACKGROUND_MODE, BackgroundMode.Image.name.lowercase())
.orEmpty()
.ifBlank { BackgroundMode.Image.name.lowercase() }
set(value) = prefs.edit().putString(KEY_BACKGROUND_MODE, value).apply()
override var backgroundColor: Int
@@ -131,20 +147,31 @@ class AndroidSecureStore(context: Context) : SecureStore {
set(value) = prefs.edit().putInt(KEY_BACKGROUND_COLOR, value).apply()
override var backgroundImagePath: String
get() = prefs.getString(KEY_BACKGROUND_IMAGE_PATH, "").orEmpty()
get() =
prefs
.getString(KEY_BACKGROUND_IMAGE_PATH, Backdrop.DEFAULT.path)
.orEmpty()
.ifBlank { Backdrop.DEFAULT.path }
set(value) = prefs.edit().putString(KEY_BACKGROUND_IMAGE_PATH, value).apply()
override var fontSizeScale: Float
get() = prefs.getFloat(KEY_FONT_SIZE_SCALE, 1.0f)
set(value) = prefs.edit().putFloat(KEY_FONT_SIZE_SCALE, value).apply()
override fun savePairing(url: String, token: String) {
override fun savePairing(
url: String,
token: String,
) {
serverUrl = url
this.token = token
}
override fun clear() {
prefs.edit().remove(KEY_URL).remove(KEY_TOKEN).apply()
prefs
.edit()
.remove(KEY_URL)
.remove(KEY_TOKEN)
.apply()
}
private companion object {
@@ -35,21 +35,49 @@ expect fun mediaCacheBaseDir(): String
expect suspend fun loadMediaImage(path: String): ImageBitmap?
/** Load an image scaled to at most [maxSize] px on the longest edge. Null on failure. */
expect suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap?
expect suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap?
/**
* Decode an image from raw bytes (bundled backdrops), scaled to at most
* [maxSize] px on the longest edge. Null on failure.
*/
expect suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap?
/**
* Read a bundled backdrop image (shipped in the app) by its [id] (the file
* name without extension, e.g. `pexels-yunszyveli-12368637`). Android reads it
* from `assets/backdrops/`, desktop from the classpath. Null on failure.
*/
expect fun readBackdropBytes(id: String): ByteArray?
/**
* Encode a local image as a base64 PNG, downscaled to at most [maxSize] px on
* the longest edge. Null on failure. Used for channel icons (stored on the
* gateway, synced across devices).
*/
expect suspend fun encodeImageAsBase64(path: String, maxSize: Int): String?
expect suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String?
/** Decode a base64-encoded image (PNG/JPEG) into an [ImageBitmap]. Null on failure. */
expect suspend fun decodeBase64Image(base64: String): ImageBitmap?
/** Inline audio/video player (ExoPlayer on Android; placeholder on desktop). */
@Composable
expect fun MediaPlayerView(media: MediaItem, modifier: Modifier)
expect fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
)
/** Open a document with the system handler (best effort). */
expect fun openDocument(path: String, mime: String)
expect fun openDocument(
path: String,
mime: String,
)
@@ -1,7 +1,7 @@
package iris.state
import iris.data.ChatStore
import iris.data.ChannelStore
import iris.data.ChatStore
import iris.data.MediaItem
import iris.data.MessageItem
import iris.data.MsgStatus
@@ -24,22 +24,21 @@ import iris.protocol.MessageDeletedPayload
import iris.protocol.MessagePayload
import iris.protocol.MessageStopPayload
import iris.protocol.NotificationPayload
import iris.protocol.ReadReceiptPayload
import iris.protocol.ROLE_ASSISTANT
import iris.protocol.ReadReceiptPayload
import iris.protocol.SearchHit
import iris.protocol.SearchResultsPayload
import iris.protocol.SlashCommand
import iris.protocol.StatusPayload
import iris.protocol.SyncDonePayload
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_CHANNEL_CREATED
import iris.protocol.TYPE_CHANNEL_DELETED
import iris.protocol.TYPE_CHANNEL_LIST
import iris.protocol.TYPE_CHANNEL_RENAMED
import iris.protocol.TYPE_COMMANDS_CATALOG
import iris.protocol.TYPE_COMMENTARY
import iris.protocol.TYPE_ERROR
import iris.protocol.TYPE_HISTORY
import iris.protocol.TYPE_MEDIA_OFFER
import iris.protocol.TYPE_MESSAGE
import iris.protocol.TYPE_MESSAGE_DELETE
@@ -47,6 +46,7 @@ import iris.protocol.TYPE_MESSAGE_DELETED
import iris.protocol.TYPE_MESSAGE_START
import iris.protocol.TYPE_MESSAGE_STOP
import iris.protocol.TYPE_MESSAGE_UPDATE
import iris.protocol.TYPE_NOTIFICATION
import iris.protocol.TYPE_READ_RECEIPT
import iris.protocol.TYPE_SEARCH_RESULTS
import iris.protocol.TYPE_STATUS
@@ -69,6 +69,7 @@ import iris.protocol.historyFrame
import iris.protocol.messageDeleteFrame
import iris.protocol.searchFrame
import iris.protocol.syncFrame
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.coroutines.CoroutineScope
@@ -105,7 +106,8 @@ class IrisController(
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
fun cycleToolDetail() {
_toolDetail.value = when (_toolDetail.value) {
_toolDetail.value =
when (_toolDetail.value) {
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
@@ -170,16 +172,18 @@ class IrisController(
// ── Appearance (Settings → Appearance) ────────────────────────────────
// User-customizable bubble colors + background (color or image).
// Persisted as ARGB ints + an image path (see UserTheme).
private val _theme = MutableStateFlow(
private val _theme =
MutableStateFlow(
UserTheme(
userBubbleColor = store.userBubbleColor,
agentBubbleColor = store.agentBubbleColor,
backgroundMode = BackgroundMode.entries.firstOrNull {
backgroundMode =
BackgroundMode.entries.firstOrNull {
it.name.equals(store.backgroundMode, ignoreCase = true)
} ?: BackgroundMode.Color,
backgroundColor = store.backgroundColor,
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
)
),
)
val theme: StateFlow<UserTheme> = _theme.asStateFlow()
@@ -189,13 +193,25 @@ class IrisController(
fun setBackgroundColor(argb: Int) = updateTheme { it.copy(backgroundColor = argb) }
fun setBackgroundMode(mode: BackgroundMode) = updateTheme { it.copy(backgroundMode = mode) }
fun setBackgroundMode(mode: BackgroundMode) =
updateTheme {
// Switching to image mode without an image falls back to the default
// bundled backdrop instead of silently showing the solid color.
it.copy(
backgroundMode = mode,
backgroundImagePath =
if (mode == BackgroundMode.Image && it.backgroundImagePath.isNullOrBlank()) {
Backdrop.DEFAULT.path
} else {
it.backgroundImagePath
},
)
}
fun setBackgroundImagePath(path: String?) = updateTheme { it.copy(backgroundImagePath = path) }
/** Clears the background image and falls back to the solid color. */
fun removeBackgroundImage() =
updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
fun removeBackgroundImage() = updateTheme { it.copy(backgroundImagePath = null, backgroundMode = BackgroundMode.Color) }
fun resetTheme() = updateTheme { UserTheme() }
@@ -261,6 +277,7 @@ class IrisController(
val attachments: StateFlow<List<PendingAttachment>> = _attachments.asStateFlow()
// ── M5: notification banners (docs/08 §8.3) ───────────────────────────
/** An in-app notification banner shown above the composer. */
data class Banner(
val id: Long,
@@ -280,7 +297,13 @@ class IrisController(
_banners.value = _banners.value.filterNot { it.id == id }
}
private fun pushBanner(kind: String, title: String, body: String, chatId: String?, threadId: String?) {
private fun pushBanner(
kind: String,
title: String,
body: String,
chatId: String?,
threadId: String?,
) {
val persistent = kind in HIGH_PRIORITY_NOTIF_KINDS
val banner = Banner(bannerSeq++, kind, title, body, chatId, threadId, persistent)
_banners.value = (_banners.value + banner).takeLast(5)
@@ -299,7 +322,11 @@ class IrisController(
* the chat (deep link). The same per-chat notification id is reused, so a
* multi-segment turn updates one notification instead of stacking.
*/
private fun notifyMessageIfBackgrounded(chatId: String?, threadId: String?, text: String) {
private fun notifyMessageIfBackgrounded(
chatId: String?,
threadId: String?,
text: String,
) {
if (isAppForeground()) return
if (text.isBlank()) return
val id = chatId ?: "android:default"
@@ -308,7 +335,10 @@ class IrisController(
}
/** Single-line preview for a notification body (lock-screen privacy). */
private fun preview(text: String, limit: Int = 120): String {
private fun preview(
text: String,
limit: Int = 120,
): String {
val flat = text.replace('\n', ' ').replace('\t', ' ').trim()
return if (flat.length <= limit) flat else flat.take(limit).trimEnd() + "…"
}
@@ -318,7 +348,10 @@ class IrisController(
/** Open the chat referenced by a push notification tap. Applied now if
* connected, otherwise on the next hello.ack (lane may not exist yet). */
fun openDeepLink(chatId: String?, threadId: String?) {
fun openDeepLink(
chatId: String?,
threadId: String?,
) {
if (chatId.isNullOrBlank()) return
pendingDeepLink = chatId to threadId
if (client.state.value is GatewayClient.State.Connected) applyDeepLink()
@@ -343,7 +376,8 @@ class IrisController(
TYPE_TOOL_PROGRESS,
TYPE_TOOL_END,
TYPE_COMMENTARY,
TYPE_MEDIA_OFFER -> {
TYPE_MEDIA_OFFER,
-> {
chat.onFrame(frame)
// M4: pull offered media into the local cache.
if (frame.type == TYPE_MEDIA_OFFER) {
@@ -356,23 +390,31 @@ class IrisController(
// message.stop; non-streaming replies are a single
// assistant `message`.
when (frame.type) {
TYPE_MESSAGE_STOP ->
TYPE_MESSAGE_STOP -> {
frame.payloadAs<MessageStopPayload>()?.let {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.finalText)
}
TYPE_MESSAGE ->
}
TYPE_MESSAGE -> {
frame.payloadAs<MessagePayload>()?.let {
if (it.role == ROLE_ASSISTANT) {
notifyMessageIfBackgrounded(frame.chatId, frame.threadId, it.text)
}
}
else -> Unit
}
else -> {
Unit
}
}
}
TYPE_CHANNEL_CREATED,
TYPE_CHANNEL_RENAMED,
TYPE_CHANNEL_DELETED,
TYPE_CHANNEL_LIST -> {
TYPE_CHANNEL_LIST,
-> {
channels.onFrame(frame)
// Auto-created thread (Settings → "Threads"): the
// gateway minted it for a message in the flat lane we
@@ -396,26 +438,37 @@ class IrisController(
frame.payloadAs<ChannelDeletedPayload>()?.let { p ->
val (curChat, curThread) = chat.parseLane(chat.currentLane.value)
when {
curThread == p.chatId -> openChannel(curChat)
curChat == p.chatId ->
curThread == p.chatId -> {
openChannel(curChat)
}
curChat == p.chatId -> {
channels.defaultChannel()?.let { openChannel(it.chatId) }
else -> Unit
}
else -> {
Unit
}
}
}
}
}
TYPE_SEARCH_RESULTS -> {
frame.payloadAs<SearchResultsPayload>()?.let { _searchResults.value = it.hits }
_searching.value = false
}
TYPE_COMMANDS_CATALOG -> {
frame.payloadAs<CommandsCatalogPayload>()?.let { _slashCommands.value = it.commands }
}
TYPE_SYNC_DONE -> {
// Replayed frames already flowed through [events]; the
// cursor is authoritative server-side (outbox).
frame.payloadAs<SyncDonePayload>()?.let { store.syncCursor = it.cursor }
}
TYPE_HISTORY -> {
// Full message history for a chat/thread (initial open /
// restart restore). `sync` only replays the outbox delta
@@ -432,6 +485,7 @@ class IrisController(
)
}
}
TYPE_NOTIFICATION -> {
frame.payloadAs<NotificationPayload>()?.let { p ->
pushBanner(p.kind, p.title, p.body, p.chatId, p.threadId)
@@ -444,12 +498,15 @@ class IrisController(
}
}
}
TYPE_TYPING -> {
frame.payloadAs<TypingPayload>()?.let { _typing.value = it.on }
}
TYPE_READ_RECEIPT -> {
frame.payloadAs<ReadReceiptPayload>()?.let { chat.markRead(it.messageId) }
}
TYPE_MESSAGE_DELETED -> {
// A message was deleted (by this or another device).
// Idempotent: dropping an unknown id is a no-op.
@@ -457,16 +514,21 @@ class IrisController(
chat.removeMessages(it.messageIds.toSet())
}
}
TYPE_STATUS -> {
frame.payloadAs<StatusPayload>()?.let { _gatewayStatus.value = it.state }
}
TYPE_ERROR -> {
// M7: error frames are global, not per-message — fail
// any optimistic sends still in flight so they don't
// sit at "sending…" forever.
chat.failPending()
}
else -> Unit
else -> {
Unit
}
}
}
}
@@ -537,7 +599,10 @@ class IrisController(
/** Switch to a specific thread lane under [chatId]. Loads the full history
* on first open this session. */
fun openThread(chatId: String, threadId: String) {
fun openThread(
chatId: String,
threadId: String,
) {
chat.setLane(chat.laneKey(chatId, threadId))
loadHistory(chatId, threadId)
}
@@ -550,13 +615,19 @@ class IrisController(
client.sendFrame(channelCreateFrame(0, trimmed))
}
fun createThread(name: String, parentChatId: String) {
fun createThread(
name: String,
parentChatId: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
client.sendFrame(channelCreateFrame(0, trimmed, kind = "thread", parentChatId = parentChatId))
}
fun renameChannel(chatId: String, name: String) {
fun renameChannel(
chatId: String,
name: String,
) {
val trimmed = name.trim()
if (trimmed.isEmpty()) return
client.sendFrame(channelRenameFrame(0, chatId, trimmed))
@@ -567,19 +638,29 @@ class IrisController(
}
/** Toggle the cosmetic favorite flag (sorts the channel to the top). */
fun setChannelFavorite(chatId: String, on: Boolean) {
fun setChannelFavorite(
chatId: String,
on: Boolean,
) {
client.sendFrame(channelFavoriteFrame(0, chatId, on))
}
/** Mark the channel as automation (read-only; cron/webhook output only).
* The default channel cannot be marked (the server enforces this too). */
fun setChannelAutomation(chatId: String, on: Boolean) {
fun setChannelAutomation(
chatId: String,
on: Boolean,
) {
client.sendFrame(channelSetAutomationFrame(0, chatId, on))
}
/** Set the channel's cosmetic icon (base64 image) and/or avatar color.
* Pass null to clear a field. */
fun setChannelIcon(chatId: String, icon: String?, color: String?) {
fun setChannelIcon(
chatId: String,
icon: String?,
color: String?,
) {
client.sendFrame(channelIconFrame(0, chatId, icon, color))
}
@@ -593,7 +674,12 @@ class IrisController(
// ── M3: search ────────────────────────────────────────────────────────
fun search(query: String, scope: String = "all", chatId: String? = null, threadId: String? = null) {
fun search(
query: String,
scope: String = "all",
chatId: String? = null,
threadId: String? = null,
) {
val q = query.trim()
if (q.isEmpty()) return
_lastQuery.value = q
@@ -626,7 +712,10 @@ class IrisController(
* Populates the lane via the `history` response; used on first open of a
* chat and after a process death (restart) to restore the view, since the
* `sync` delta does not cover messages older than the saved cursor. */
fun loadHistory(chatId: String, threadId: String? = null) {
fun loadHistory(
chatId: String,
threadId: String? = null,
) {
val lane = chat.laneKey(chatId, threadId)
if (lane in historyLoaded) return
historyLoaded.add(lane)
@@ -637,16 +726,23 @@ class IrisController(
/** Optimistic send: show immediately in the current lane, then hand to the gateway.
* M4: [attachments] (uploaded) are attached via media_refs. */
fun send(text: String, attachments: List<PendingAttachment> = emptyList()) {
fun send(
text: String,
attachments: List<PendingAttachment> = emptyList(),
) {
val trimmed = text.trim()
val refs = attachments.mapNotNull { it.mediaRef }
if (trimmed.isEmpty() && refs.isEmpty()) return
val lane = chat.currentLane.value
val (chatId, threadId) = chat.parseLane(lane)
val media = attachments.map {
val media =
attachments.map {
MediaItem(
mediaId = it.mediaRef ?: it.filename,
kind = it.kind, mime = it.mime, size = it.size, filename = it.filename,
kind = it.kind,
mime = it.mime,
size = it.size,
filename = it.filename,
localPath = it.path,
)
}
@@ -660,7 +756,11 @@ class IrisController(
* gateway (Telegram topic-mode workflow). Threading is only active on
* the default channel; other channels stay flat. Slash commands and
* media-only sends stay in the flat lane. */
private fun wantsAutoThread(text: String, threadId: String?, chatId: String): Boolean =
private fun wantsAutoThread(
text: String,
threadId: String?,
chatId: String,
): Boolean =
_threadsEnabled.value &&
threadId == null &&
text.isNotEmpty() &&
@@ -700,27 +800,35 @@ class IrisController(
/** Stage a picked file: upload it, then keep it as a pending attachment. */
fun attachFile(picked: PickedFile) {
val kind = kindFromMime(picked.mime)
val placeholder = PendingAttachment(
filename = picked.name, mime = picked.mime, size = picked.size, kind = kind,
val placeholder =
PendingAttachment(
filename = picked.name,
mime = picked.mime,
size = picked.size,
kind = kind,
path = picked.path,
)
_attachments.value = _attachments.value + placeholder
scope.launch {
val mediaRef = "up_${Random.nextLong(1_000_000_000L, 9_999_999_999L)}"
val result = client.uploadMedia(
val result =
client.uploadMedia(
path = picked.path,
mime = picked.mime,
kind = kind,
filename = picked.name,
mediaRef = mediaRef,
)
_attachments.value = _attachments.value.map {
_attachments.value =
_attachments.value.map {
if (it.filename == picked.name && it.uploading) {
result.fold(
{ ref -> it.copy(uploading = false, mediaRef = ref) },
{ e -> it.copy(uploading = false, error = e.message) },
)
} else it
} else {
it
}
}
}
}
@@ -748,7 +856,10 @@ class IrisController(
}
/** Connect-screen action: real hello test, then save + (re)connect. */
suspend fun connect(url: String, token: String): Result<Unit> {
suspend fun connect(
url: String,
token: String,
): Result<Unit> {
val test = client.testHello(url, token)
if (test.isFailure) return test
store.savePairing(url, token)
@@ -779,7 +890,8 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
reasoning = reasoning,
model = model,
tokens = tokens,
media = media.map {
media =
media.map {
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
},
)
@@ -804,7 +916,6 @@ enum class ToolDetail {
companion object {
/** Parse a persisted value; unknown/blank falls back to the default. */
fun fromName(name: String): ToolDetail =
entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED
fun fromName(name: String): ToolDetail = entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: TRUNCATED
}
}
File diff suppressed because it is too large. Load diff
@@ -8,6 +8,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
@@ -44,9 +45,12 @@ import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.platform.ImageFilePicker
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
import iris.state.IrisController
import iris.state.ToolDetail
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.IrisColors
import iris.ui.theme.LocalUserTheme
@@ -56,11 +60,15 @@ import kotlin.math.roundToInt
* Settings screen (post-M7): app-level preferences, opened from the channel
* drawer / rail. More settings land here over time.
*/
/** Which color the picker dialog is editing. */
private enum class PickerTarget { UserBubble, AgentBubble, Background }
@Composable
fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
fun SettingsScreen(
controller: IrisController,
onBack: () -> Unit,
) {
val threadsEnabled by controller.threadsEnabled.collectAsState()
val streamingEnabled by controller.streamingEnabled.collectAsState()
val reasoningAutoCollapse by controller.reasoningAutoCollapse.collectAsState()
@@ -71,7 +79,8 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
@@ -220,14 +229,36 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
ColorSwatch(theme.background) { pickerTarget = PickerTarget.Background }
}
} else {
Spacer(modifier = Modifier.height(8.dp))
Text("Backdrops", fontSize = 12.sp, color = IrisColors.textDim)
Spacer(modifier = Modifier.height(6.dp))
FlowRow(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Backdrop.ALL.forEach { backdrop ->
BackdropThumb(
backdrop = backdrop,
selected = theme.backgroundImagePath == backdrop.path,
onClick = {
controller.setBackgroundMode(BackgroundMode.Image)
controller.setBackgroundImagePath(backdrop.path)
},
)
}
}
Spacer(modifier = Modifier.height(8.dp))
Row(verticalAlignment = Alignment.CenterVertically) {
BackgroundPreview(theme.backgroundImagePath)
Spacer(modifier = Modifier.width(8.dp))
Column(modifier = Modifier.weight(1f)) {
Text(
if (theme.backgroundImagePath.isNullOrBlank()) "No image selected"
else fileNameOf(theme.backgroundImagePath!!),
if (theme.backgroundImagePath.isNullOrBlank()) {
"No image selected"
} else {
Backdrop.fromPath(theme.backgroundImagePath)?.name
?: fileNameOf(theme.backgroundImagePath!!)
},
fontSize = 12.sp,
color = IrisColors.textDim,
)
@@ -328,7 +359,8 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
}
when (pickerTarget) {
PickerTarget.UserBubble -> ColorPickerDialog(
PickerTarget.UserBubble -> {
ColorPickerDialog(
title = "Your bubble",
initialArgb = theme.userBubbleColor,
onApply = { controller.setUserBubbleColor(it) },
@@ -338,7 +370,10 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
},
onDismiss = { pickerTarget = null },
)
PickerTarget.AgentBubble -> ColorPickerDialog(
}
PickerTarget.AgentBubble -> {
ColorPickerDialog(
title = "Agent bubble",
initialArgb = theme.agentBubbleColor,
onApply = { controller.setAgentBubbleColor(it) },
@@ -348,7 +383,10 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
},
onDismiss = { pickerTarget = null },
)
PickerTarget.Background -> ColorPickerDialog(
}
PickerTarget.Background -> {
ColorPickerDialog(
title = "Background",
initialArgb = theme.backgroundColor,
onApply = { controller.setBackgroundColor(it) },
@@ -358,17 +396,25 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
},
onDismiss = { pickerTarget = null },
)
null -> Unit
}
null -> {
Unit
}
}
}
}
/** Material-style back arrow (vector, matches the app's icon weight). */
@Composable
private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
private fun BackArrow(
size: Dp = 22.dp,
color: Color = IrisColors.onSurface,
) {
Canvas(modifier = Modifier.size(size)) {
val s = this.size.width / 24f
val path = Path().apply {
val path =
Path().apply {
moveTo(20f * s, 11f * s)
lineTo(7.83f * s, 11f * s)
lineTo(13.42f * s, 5.41f * s)
@@ -388,7 +434,8 @@ private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
@Composable
private fun SettingsCard(content: @Composable () -> Unit) {
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(12.dp))
.background(IrisColors.panel)
@@ -400,9 +447,13 @@ private fun SettingsCard(content: @Composable () -> Unit) {
/** Clickable rounded chip showing a color; opens the picker on tap. */
@Composable
private fun ColorSwatch(color: Color, onClick: () -> Unit) {
private fun ColorSwatch(
color: Color,
onClick: () -> Unit,
) {
Box(
modifier = Modifier
modifier =
Modifier
.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(color)
@@ -416,10 +467,21 @@ private fun ColorSwatch(color: Color, onClick: () -> Unit) {
private fun BackgroundPreview(path: String?) {
var bmp by remember(path) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(path) {
bmp = if (path.isNullOrBlank()) null else loadScaledImage(path, 128)
bmp =
if (path.isNullOrBlank()) {
null
} else {
val bytes = Backdrop.fromPath(path)?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 128)
} else {
loadScaledImage(path, 128)
}
}
}
Box(
modifier = Modifier
modifier =
Modifier
.size(44.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
@@ -438,6 +500,43 @@ private fun BackgroundPreview(path: String?) {
}
}
/** Thumbnail of a bundled backdrop; highlighted when selected. */
@Composable
private fun BackdropThumb(
backdrop: Backdrop,
selected: Boolean,
onClick: () -> Unit,
) {
var bmp by remember(backdrop.id) { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(backdrop.id) {
val bytes = readBackdropBytes(backdrop.id)
bmp = if (bytes != null) decodeImageBytes(bytes, 128) else null
}
Box(
modifier =
Modifier
.size(64.dp)
.clip(RoundedCornerShape(8.dp))
.background(IrisColors.panel)
.border(
if (selected) 2.dp else 1.dp,
if (selected) IrisColors.primary else IrisColors.divider,
RoundedCornerShape(8.dp),
).clickable(onClick = onClick),
contentAlignment = Alignment.Center,
) {
val b = bmp
if (b != null) {
Image(
bitmap = b,
contentDescription = backdrop.name,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)
}
}
}
/**
* HSV color picker dialog (hue / saturation / brightness sliders).
* [onApply] applies the color and keeps the dialog open for further tweaks;
@@ -462,7 +561,8 @@ private fun ColorPickerDialog(
text = {
Column(modifier = Modifier.fillMaxWidth()) {
Box(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.height(48.dp)
.clip(RoundedCornerShape(8.dp))
@@ -505,7 +605,8 @@ private fun argbToHsv(argb: Int): FloatArray {
val delta = max - min
var hue = 0f
if (delta != 0f) {
hue = when (max) {
hue =
when (max) {
r -> ((g - b) / delta) % 6f
g -> (b - r) / delta + 2f
else -> (r - g) / delta + 4f
@@ -518,5 +619,4 @@ private fun argbToHsv(argb: Int): FloatArray {
}
/** File name from a path (no java.io.File in common code). */
private fun fileNameOf(path: String): String =
path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
private fun fileNameOf(path: String): String = path.substringAfterLast('/').ifEmpty { path.substringAfterLast('\\') }
@@ -0,0 +1,40 @@
package iris.ui.theme
/**
* A bundled backdrop image shipped in the app (Android `assets/backdrops/`,
* desktop classpath `backdrops/`), loaded via [iris.platform.readBackdropBytes].
*
* Bundled backdrops are referenced in [UserTheme.backgroundImagePath] by the
* sentinel path `backdrop://<id>` (see [path]); user-picked images use a real
* file path. [DEFAULT] is the background for fresh installs.
*/
data class Backdrop(
val id: String,
val name: String,
) {
/** Sentinel path stored in the theme for this bundled backdrop. */
val path: String get() = "backdrop://$id"
companion object {
const val PATH_PREFIX = "backdrop://"
val ALL: List<Backdrop> =
listOf(
Backdrop("pexels-yunszyveli-12368637", "Yun Syzveli"),
Backdrop("pexels-bogdankrupin-12049700", "Bogdan Krupin"),
Backdrop("pexels-bosichong-27940302", "Bosi Chong"),
Backdrop("pexels-farhan-najeer-644774196-32490483", "Farhan Najeer"),
Backdrop("pexels-soubhagya23-8060218", "Soubhagya"),
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. */
fun isBackdropPath(path: String?): Boolean = !path.isNullOrBlank() && path.startsWith(PATH_PREFIX)
/** The bundled backdrop for a sentinel path, or null. */
fun fromPath(path: String?): Backdrop? = if (isBackdropPath(path)) ALL.firstOrNull { it.path == path } else null
}
}
@@ -11,7 +11,9 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ImageBitmap
import iris.platform.decodeImageBytes
import iris.platform.loadScaledImage
import iris.platform.readBackdropBytes
/**
* Centralized palette (M7). Dark is the only theme; every screen reads colors
@@ -54,7 +56,8 @@ object IrisColors {
val errorText = Color(0xFFFF8A80)
}
private val IrisDark = darkColorScheme(
private val IrisDark =
darkColorScheme(
background = IrisColors.background,
surface = IrisColors.surface,
onBackground = IrisColors.onBackground,
@@ -63,7 +66,7 @@ private val IrisDark = darkColorScheme(
primary = IrisColors.primary,
onPrimary = Color.White,
error = IrisColors.statusRed,
)
)
@Composable
fun IrisTheme(content: @Composable () -> Unit) {
@@ -85,9 +88,11 @@ fun avatarColor(name: String): Color {
data class UserTheme(
val userBubbleColor: Int = DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = DEFAULT_AGENT_BUBBLE,
val backgroundMode: BackgroundMode = BackgroundMode.Color,
// Fresh installs start with the default bundled backdrop (Settings →
// Appearance to change it).
val backgroundMode: BackgroundMode = BackgroundMode.Image,
val backgroundColor: Int = DEFAULT_BACKGROUND,
val backgroundImagePath: String? = null,
val backgroundImagePath: String? = Backdrop.DEFAULT.path,
) {
val userBubble: Color get() = Color(userBubbleColor)
val agentBubble: Color get() = Color(agentBubbleColor)
@@ -114,8 +119,10 @@ fun Color.contrastText(): Color {
/**
* Decodes the theme's background image (image mode only), scaled to at most
* 2048 px on the longest edge. Null in color mode or on decode failure — the
* caller then falls back to the solid background color.
* 2048 px on the longest edge. Bundled backdrops (`backdrop://<id>`) are read
* from the app resources; anything else is a user-picked file path. Null in
* color mode or on decode failure — the caller then falls back to the solid
* background color.
*/
@Composable
fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
@@ -123,7 +130,18 @@ fun rememberBackgroundImage(theme: UserTheme): ImageBitmap? {
val wantImage = theme.backgroundMode == BackgroundMode.Image && !path.isNullOrBlank()
var image by remember { mutableStateOf<ImageBitmap?>(null) }
LaunchedEffect(wantImage, path) {
image = if (wantImage) loadScaledImage(path!!, 2048) else null
image =
if (!wantImage) {
null
} else {
val backdrop = Backdrop.fromPath(path)
val bytes = backdrop?.let { readBackdropBytes(it.id) }
if (bytes != null) {
decodeImageBytes(bytes, 2048)
} else {
loadScaledImage(path!!, 2048)
}
}
}
return image
}
@@ -1,5 +1,7 @@
package iris.platform
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
@@ -8,8 +10,6 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.material3.Button
import androidx.compose.material3.IconButton
import androidx.compose.material3.Slider
@@ -31,17 +31,6 @@ import androidx.compose.ui.graphics.toComposeImageBitmap
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import iris.data.MediaItem
import java.awt.image.BufferedImage
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.StandardProtocolFamily
import java.net.UnixDomainSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.SocketChannel
import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.withContext
@@ -53,6 +42,18 @@ import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.File
import java.net.StandardProtocolFamily
import java.net.UnixDomainSocketAddress
import java.nio.ByteBuffer
import java.nio.channels.SocketChannel
import java.util.concurrent.TimeUnit
import javax.imageio.ImageIO
import javax.swing.JFileChooser
import javax.swing.filechooser.FileNameExtensionFilter
import kotlin.random.Random
/**
@@ -63,7 +64,8 @@ import kotlin.random.Random
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
var busy by remember { mutableStateOf(false) }
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.padding(16.dp),
) {
@@ -98,7 +100,8 @@ private fun pickFile(): PickedFile? {
@Composable
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
Button(onClick = {
val chooser = JFileChooser().apply {
val chooser =
JFileChooser().apply {
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
}
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
@@ -151,16 +154,21 @@ actual fun mediaCacheBaseDir(): String {
return dir.absolutePath
}
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun loadMediaImage(path: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
img.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
}
actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun loadScaledImage(
path: String,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -178,9 +186,46 @@ actual suspend fun loadScaledImage(path: String, maxSize: Int): ImageBitmap? = w
} catch (_: Exception) {
null
}
}
}
actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = withContext(Dispatchers.IO) {
actual suspend fun decodeImageBytes(
bytes: ByteArray,
maxSize: Int,
): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(ByteArrayInputStream(bytes)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
if (scale >= 1f) {
img.toComposeImageBitmap()
} else {
val sw = (img.width * scale).toInt().coerceAtLeast(1)
val sh = (img.height * scale).toInt().coerceAtLeast(1)
val scaled = BufferedImage(sw, sh, BufferedImage.TYPE_INT_ARGB)
val g = scaled.createGraphics()
g.drawImage(img, 0, 0, sw, sh, null)
g.dispose()
scaled.toComposeImageBitmap()
}
} catch (_: Exception) {
null
}
}
actual fun readBackdropBytes(id: String): ByteArray? =
try {
// Top-level function: no `javaClass`, so anchor the classpath lookup to a
// class in this module (PickedFile, same package).
PickedFile::class.java.getResourceAsStream("/backdrops/$id.jpg")?.use { it.readBytes() }
} catch (e: Exception) {
null
}
actual suspend fun encodeImageAsBase64(
path: String,
maxSize: Int,
): String? =
withContext(Dispatchers.IO) {
try {
val img = ImageIO.read(File(path)) ?: return@withContext null
val scale = (maxSize.toFloat() / maxOf(img.width, img.height)).coerceAtMost(1f)
@@ -192,21 +237,27 @@ actual suspend fun encodeImageAsBase64(path: String, maxSize: Int): String? = wi
g.dispose()
val baos = ByteArrayOutputStream()
ImageIO.write(scaled, "png", baos)
java.util.Base64.getEncoder().encodeToString(baos.toByteArray())
java.util.Base64
.getEncoder()
.encodeToString(baos.toByteArray())
} catch (_: Exception) {
null
}
}
}
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
actual suspend fun decodeBase64Image(base64: String): ImageBitmap? =
withContext(Dispatchers.IO) {
try {
val bytes = java.util.Base64.getDecoder().decode(base64)
val bytes =
java.util.Base64
.getDecoder()
.decode(base64)
val img = ImageIO.read(java.io.ByteArrayInputStream(bytes)) ?: return@withContext null
img.toComposeImageBitmap()
} catch (_: Exception) {
null
}
}
}
/**
* M6: inline media playback backed by mpv (docs/11 §11.2).
@@ -219,7 +270,10 @@ actual suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext
* - no mpv on PATH: an info chip (the WebView fallback is a stretch goal).
*/
@Composable
actual fun MediaPlayerView(media: MediaItem, modifier: Modifier) {
actual fun MediaPlayerView(
media: MediaItem,
modifier: Modifier,
) {
val path = media.localPath
if (path == null || !File(path).exists()) {
Box(modifier = modifier.fillMaxWidth(), contentAlignment = Alignment.Center) {
@@ -246,7 +300,12 @@ private object Mpv {
val available: Boolean by lazy {
try {
val p = ProcessBuilder("which", "mpv").redirectErrorStream(true).start()
val found = p.inputStream.bufferedReader().readText().trim().isNotBlank()
val found =
p.inputStream
.bufferedReader()
.readText()
.trim()
.isNotBlank()
p.waitFor(2, TimeUnit.SECONDS)
found
} catch (_: Exception) {
@@ -256,7 +315,10 @@ private object Mpv {
}
@Composable
private fun AudioPlayer(media: MediaItem, path: String) {
private fun AudioPlayer(
media: MediaItem,
path: String,
) {
val session = remember(path) { MpvAudioSession(path) }
var paused by remember { mutableStateOf(true) }
var position by remember { mutableStateOf(0.0) }
@@ -278,7 +340,8 @@ private fun AudioPlayer(media: MediaItem, path: String) {
}
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
@@ -309,10 +372,14 @@ private fun AudioPlayer(media: MediaItem, path: String) {
}
@Composable
private fun VideoPlayer(media: MediaItem, path: String) {
private fun VideoPlayer(
media: MediaItem,
path: String,
) {
var process by remember { mutableStateOf<Process?>(null) }
Column(
modifier = Modifier
modifier =
Modifier
.fillMaxWidth()
.clip(RoundedCornerShape(10.dp))
.background(Color(0xFF20242E))
@@ -352,7 +419,9 @@ private fun fmtTime(sec: Double): String {
* An mpv process + its JSON IPC socket (unix domain). Commands carry a
* [requestId] so responses can be matched and async events skipped.
*/
private class MpvAudioSession(private val path: String) {
private class MpvAudioSession(
private val path: String,
) {
private var process: Process? = null
private var channel: SocketChannel? = null
private val socketPath =
@@ -362,9 +431,14 @@ private class MpvAudioSession(private val path: String) {
fun start() {
try {
process = ProcessBuilder(
"mpv", "--no-video", "--really-quiet", "--no-terminal",
"--input-ipc-server=$socketPath", path,
process =
ProcessBuilder(
"mpv",
"--no-video",
"--really-quiet",
"--no-terminal",
"--input-ipc-server=$socketPath",
path,
).redirectErrorStream(true).start()
connectIpc()
} catch (_: Exception) {
@@ -406,9 +480,24 @@ private class MpvAudioSession(private val path: String) {
} catch (_: Exception) {
}
}
answers["p1"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { pos = it }
answers["p2"]?.get("data")?.jsonPrimitive?.content?.toDoubleOrNull()?.let { dur = it }
answers["p3"]?.get("data")?.jsonPrimitive?.content?.toBooleanStrictOrNull()?.let { pause = it }
answers["p1"]
?.get("data")
?.jsonPrimitive
?.content
?.toDoubleOrNull()
?.let { pos = it }
answers["p2"]
?.get("data")
?.jsonPrimitive
?.content
?.toDoubleOrNull()
?.let { dur = it }
answers["p3"]
?.get("data")
?.jsonPrimitive
?.content
?.toBooleanStrictOrNull()
?.let { pause = it }
} catch (_: Exception) {
}
return Triple(pos, dur, pause)
@@ -440,9 +529,13 @@ private class MpvAudioSession(private val path: String) {
}
}
private fun send(cmd: List<String>, requestId: String?) {
private fun send(
cmd: List<String>,
requestId: String?,
) {
val ch = channel ?: return
val obj = buildJsonObject {
val obj =
buildJsonObject {
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
requestId?.let { put("request_id", JsonPrimitive(it)) }
}
@@ -471,14 +564,24 @@ private class MpvAudioSession(private val path: String) {
}
}
actual fun openDocument(path: String, mime: String) {
actual fun openDocument(
path: String,
mime: String,
) {
try {
val os = System.getProperty("os.name").lowercase()
when {
os.contains("mac") -> ProcessBuilder("open", path).start()
os.contains("win") ->
os.contains("mac") -> {
ProcessBuilder("open", path).start()
}
os.contains("win") -> {
ProcessBuilder("rundll32", "url.dll,FileProtocolHandler", path).start()
else -> ProcessBuilder("xdg-open", path).start()
}
else -> {
ProcessBuilder("xdg-open", path).start()
}
}
} catch (_: Exception) {
}
@@ -2,14 +2,16 @@ package iris.platform
import iris.data.SecureStore
import iris.protocol.IrisJson
import iris.ui.theme.Backdrop
import iris.ui.theme.BackgroundMode
import iris.ui.theme.UserTheme
import kotlinx.serialization.Serializable
import java.io.File
import java.security.SecureRandom
import java.util.UUID
import javax.crypto.Cipher
import javax.crypto.spec.GCMParameterSpec
import javax.crypto.spec.SecretKeySpec
import kotlinx.serialization.Serializable
/**
* M6: desktop pairing storage (docs/11 §11.2).
@@ -41,9 +43,9 @@ class DesktopSecureStore : SecureStore {
val reasoningAutoCollapse: Boolean = true,
val userBubbleColor: Int = UserTheme.DEFAULT_USER_BUBBLE,
val agentBubbleColor: Int = UserTheme.DEFAULT_AGENT_BUBBLE,
val backgroundMode: String = "color",
val backgroundMode: String = BackgroundMode.Image.name.lowercase(),
val backgroundColor: Int = UserTheme.DEFAULT_BACKGROUND,
val backgroundImagePath: String = "",
val backgroundImagePath: String = Backdrop.DEFAULT.path,
val fontSizeScale: Float = 1.0f,
)
@@ -53,7 +55,8 @@ class DesktopSecureStore : SecureStore {
private fun migrateLegacy() {
if (!legacyFile.exists()) return
val legacy = try {
val legacy =
try {
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
} catch (_: Exception) {
null
@@ -108,7 +111,13 @@ class DesktopSecureStore : SecureStore {
get() {
val d = load()
if (d.deviceId.isNotBlank()) return d.deviceId
val id = "dev_" + UUID.randomUUID().toString().replace("-", "").take(16)
val id =
"dev_" +
UUID
.randomUUID()
.toString()
.replace("-", "")
.take(16)
save(d.copy(deviceId = id))
return id
}
@@ -214,7 +223,10 @@ class DesktopSecureStore : SecureStore {
save(d.copy(fontSizeScale = value))
}
override fun savePairing(url: String, token: String) {
override fun savePairing(
url: String,
token: String,
) {
val d = load()
save(d.copy(serverUrl = url.trim()))
if (token.isBlank()) secret.clear() else secret.write(token.trim())
@@ -243,7 +255,9 @@ private data class PairingData(
* Token storage: OS keyring when available, else an AES-GCM encrypted file.
* All backend failures degrade to the encrypted file (never plaintext).
*/
private class SecretBackend(private val baseDir: File) {
private class SecretBackend(
private val baseDir: File,
) {
private val encFile = File(baseDir, "pairing.enc")
private val keyFile = File(baseDir, ".key")
private val keyring: KeyringBackend? = KeyringBackend().takeIf { it.available }
@@ -318,12 +332,17 @@ private class KeyringBackend {
/** macOS Keychain (`security`) or Linux Secret Service (`secret-tool`). */
val available: Boolean by lazy {
if (isMac) true
else if (isLinux) which("secret-tool") != null
else false
if (isMac) {
true
} else if (isLinux) {
which("secret-tool") != null
} else {
false
}
}
fun read(): String? = try {
fun read(): String? =
try {
if (isMac) {
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
} else {
@@ -337,13 +356,25 @@ private class KeyringBackend {
try {
if (isMac) {
ProcessBuilder(
"security", "add-generic-password", "-U",
"-a", "iris", "-s", "iris-gateway-token", "-w", value,
"security",
"add-generic-password",
"-U",
"-a",
"iris",
"-s",
"iris-gateway-token",
"-w",
value,
).inheritIO().start().waitFor()
} else {
ProcessBuilder(
"secret-tool", "store", "--label=Iris gateway token",
"app", "iris", "token", value,
"secret-tool",
"store",
"--label=Iris gateway token",
"app",
"iris",
"token",
value,
).inheritIO().start().waitFor()
}
} catch (_: Exception) {
@@ -354,12 +385,19 @@ private class KeyringBackend {
try {
if (isMac) {
ProcessBuilder(
"security", "delete-generic-password",
"-a", "iris", "-s", "iris-gateway-token",
"security",
"delete-generic-password",
"-a",
"iris",
"-s",
"iris-gateway-token",
).inheritIO().start().waitFor()
} else {
ProcessBuilder(
"secret-tool", "clear", "app", "iris",
"secret-tool",
"clear",
"app",
"iris",
).inheritIO().start().waitFor()
}
} catch (_: Exception) {
@@ -368,14 +406,23 @@ private class KeyringBackend {
private fun out(cmd: List<String>): String? {
val p = ProcessBuilder(cmd).redirectErrorStream(true).start()
val text = p.inputStream.bufferedReader().readText().trim()
val text =
p.inputStream
.bufferedReader()
.readText()
.trim()
p.waitFor()
return text.ifBlank { null }
}
private fun which(bin: String): String? = try {
private fun which(bin: String): String? =
try {
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
val text = p.inputStream.bufferedReader().readText().trim()
val text =
p.inputStream
.bufferedReader()
.readText()
.trim()
p.waitFor()
text.ifBlank { null }
} catch (_: Exception) {
Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 758 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 269 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 610 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB