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
+1726 -965

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