Added default backdrops to the chat.
This commit is contained in:
1 parent
96b60daf08
commit
75d491fd30
33 files changed
+1738
-977
No files matched your search
@@ -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,24 +59,25 @@ object AndroidEnv {
|
||||
@Composable
|
||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
try {
|
||||
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val name = queryDisplayName(context, uri) ?: "upload"
|
||||
val dir = File(context.cacheDir, "uploads").apply { mkdirs() }
|
||||
val target = File(dir, "${System.currentTimeMillis()}${extForMime(mime)}")
|
||||
context.contentResolver.openInputStream(uri)!!.use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
try {
|
||||
val mime = context.contentResolver.getType(uri) ?: "application/octet-stream"
|
||||
val name = queryDisplayName(context, uri) ?: "upload"
|
||||
val dir = File(context.cacheDir, "uploads").apply { mkdirs() }
|
||||
val target = File(dir, "${System.currentTimeMillis()}${extForMime(mime)}")
|
||||
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) {
|
||||
Button(onClick = { launcher.launch("*/*") }) { Text("Pick a file") }
|
||||
}
|
||||
@@ -85,29 +86,33 @@ actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
@Composable
|
||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
try {
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val name = queryDisplayName(context, uri) ?: "background"
|
||||
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
|
||||
val dir = File(context.filesDir, "theme").apply { mkdirs() }
|
||||
val target = File(dir, "background${extForMime(mime)}")
|
||||
context.contentResolver.openInputStream(uri)!!.use { input ->
|
||||
target.outputStream().use { output -> input.copyTo(output) }
|
||||
val launcher =
|
||||
rememberLauncherForActivityResult(ActivityResultContracts.GetContent()) { uri ->
|
||||
if (uri == null) {
|
||||
onPicked(null)
|
||||
return@rememberLauncherForActivityResult
|
||||
}
|
||||
try {
|
||||
val mime = context.contentResolver.getType(uri) ?: "image/jpeg"
|
||||
val name = queryDisplayName(context, uri) ?: "background"
|
||||
// Persistent (filesDir), unlike the upload staging dir in cacheDir.
|
||||
val dir = File(context.filesDir, "theme").apply { mkdirs() }
|
||||
val target = File(dir, "background${extForMime(mime)}")
|
||||
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…") }
|
||||
}
|
||||
|
||||
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,59 +124,99 @@ actual fun mediaCacheBaseDir(): String =
|
||||
AndroidEnv.context.externalCacheDir?.absolutePath
|
||||
?: AndroidEnv.context.cacheDir.absolutePath
|
||||
|
||||
actual suspend fun loadMediaImage(path: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
BitmapFactory.decodeFile(path)?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
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) {
|
||||
try {
|
||||
// inJustDecodeBounds fills [bounds] and returns null by design — only
|
||||
// the outWidth/outHeight are meaningful here.
|
||||
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 }
|
||||
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
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.
|
||||
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 }
|
||||
BitmapFactory.decodeFile(path, opts)?.asImageBitmap()
|
||||
} 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)
|
||||
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 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 suspend fun decodeBase64Image(base64: String): ImageBitmap? = withContext(Dispatchers.IO) {
|
||||
actual fun readBackdropBytes(id: String): ByteArray? =
|
||||
try {
|
||||
val bytes = android.util.Base64.decode(base64, android.util.Base64.DEFAULT)
|
||||
BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
||||
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)
|
||||
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
|
||||
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,21 +253,27 @@ private fun VideoPlayerView(path: String, modifier: Modifier) {
|
||||
},
|
||||
update = {},
|
||||
onRelease = { view -> view.player?.release() },
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.height(220.dp),
|
||||
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 {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
||||
prepare()
|
||||
val player =
|
||||
remember {
|
||||
ExoPlayer.Builder(context).build().apply {
|
||||
setMediaItem(M3MediaItem.fromUri(path.toUri()))
|
||||
prepare()
|
||||
}
|
||||
}
|
||||
}
|
||||
DisposableEffect(player) { onDispose { player.release() } }
|
||||
|
||||
var playing by remember { mutableStateOf(false) }
|
||||
@@ -237,9 +291,10 @@ private fun AudioPlayerView(path: String, filename: String, modifier: Modifier)
|
||||
}
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
modifier =
|
||||
modifier
|
||||
.fillMaxWidth()
|
||||
.padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
@@ -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 {
|
||||
setDataAndType(uri, mime)
|
||||
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
|
||||
}
|
||||
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,18 +17,22 @@ 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(
|
||||
appContext,
|
||||
SECURE_PREFS_NAME,
|
||||
MasterKey.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
private val prefs: SharedPreferences =
|
||||
EncryptedSharedPreferences.create(
|
||||
appContext,
|
||||
SECURE_PREFS_NAME,
|
||||
MasterKey
|
||||
.Builder(appContext)
|
||||
.setKeyScheme(MasterKey.KeyScheme.AES256_GCM)
|
||||
.build(),
|
||||
EncryptedSharedPreferences.PrefKeyEncryptionScheme.AES256_SIV,
|
||||
EncryptedSharedPreferences.PrefValueEncryptionScheme.AES256_GCM,
|
||||
)
|
||||
|
||||
init {
|
||||
migratePlainValues()
|
||||
@@ -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 {
|
||||
@@ -168,4 +195,4 @@ class AndroidSecureStore(context: Context) : SecureStore {
|
||||
const val KEY_BACKGROUND_IMAGE_PATH = "background_image_path"
|
||||
const val KEY_FONT_SIZE_SCALE = "font_size_scale"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package iris.platform
|
||||
|
||||
actual val isDesktop: Boolean = false
|
||||
actual val isDesktop: Boolean = false
|
||||
@@ -1,4 +1,4 @@
|
||||
package iris.platform
|
||||
|
||||
/** True on the desktop target (M6: big-screen layout, shortcuts, tray). */
|
||||
expect val isDesktop: Boolean
|
||||
expect val isDesktop: Boolean
|
||||
@@ -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,11 +106,12 @@ class IrisController(
|
||||
val toolDetail: StateFlow<ToolDetail> = _toolDetail.asStateFlow()
|
||||
|
||||
fun cycleToolDetail() {
|
||||
_toolDetail.value = when (_toolDetail.value) {
|
||||
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
|
||||
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
|
||||
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
|
||||
}
|
||||
_toolDetail.value =
|
||||
when (_toolDetail.value) {
|
||||
ToolDetail.EVERYTHING -> ToolDetail.TRUNCATED
|
||||
ToolDetail.TRUNCATED -> ToolDetail.NOTHING
|
||||
ToolDetail.NOTHING -> ToolDetail.EVERYTHING
|
||||
}
|
||||
store.toolDetail = _toolDetail.value.name.lowercase()
|
||||
}
|
||||
|
||||
@@ -170,17 +172,19 @@ 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(
|
||||
UserTheme(
|
||||
userBubbleColor = store.userBubbleColor,
|
||||
agentBubbleColor = store.agentBubbleColor,
|
||||
backgroundMode = BackgroundMode.entries.firstOrNull {
|
||||
it.name.equals(store.backgroundMode, ignoreCase = true)
|
||||
} ?: BackgroundMode.Color,
|
||||
backgroundColor = store.backgroundColor,
|
||||
backgroundImagePath = store.backgroundImagePath.ifBlank { null },
|
||||
private val _theme =
|
||||
MutableStateFlow(
|
||||
UserTheme(
|
||||
userBubbleColor = store.userBubbleColor,
|
||||
agentBubbleColor = store.agentBubbleColor,
|
||||
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()
|
||||
|
||||
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 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)
|
||||
@@ -636,20 +725,27 @@ 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()) {
|
||||
* M4: [attachments] (uploaded) are attached via media_refs. */
|
||||
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 {
|
||||
MediaItem(
|
||||
mediaId = it.mediaRef ?: it.filename,
|
||||
kind = it.kind, mime = it.mime, size = it.size, filename = it.filename,
|
||||
localPath = it.path,
|
||||
)
|
||||
}
|
||||
val media =
|
||||
attachments.map {
|
||||
MediaItem(
|
||||
mediaId = it.mediaRef ?: it.filename,
|
||||
kind = it.kind,
|
||||
mime = it.mime,
|
||||
size = it.size,
|
||||
filename = it.filename,
|
||||
localPath = it.path,
|
||||
)
|
||||
}
|
||||
chat.addPending(trimmed, lane, media)
|
||||
client.sendMessage(chatId, trimmed, threadId, refs, autoThread = wantsAutoThread(trimmed, threadId, chatId))
|
||||
_attachments.value = emptyList()
|
||||
@@ -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,28 +800,36 @@ 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,
|
||||
path = picked.path,
|
||||
)
|
||||
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(
|
||||
path = picked.path,
|
||||
mime = picked.mime,
|
||||
kind = kind,
|
||||
filename = picked.name,
|
||||
mediaRef = mediaRef,
|
||||
)
|
||||
_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
|
||||
}
|
||||
val result =
|
||||
client.uploadMedia(
|
||||
path = picked.path,
|
||||
mime = picked.mime,
|
||||
kind = kind,
|
||||
filename = picked.name,
|
||||
mediaRef = mediaRef,
|
||||
)
|
||||
_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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,9 +890,10 @@ private fun HistoryMessage.toMessageItem(): MessageItem =
|
||||
reasoning = reasoning,
|
||||
model = model,
|
||||
tokens = tokens,
|
||||
media = media.map {
|
||||
MediaItem(mediaId = it.mediaId, kind = it.kind, mime = it.mime, size = it.size, filename = it.filename)
|
||||
},
|
||||
media =
|
||||
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
|
||||
@@ -797,14 +909,13 @@ private const val GATEWAY_ONLINE_MSG =
|
||||
|
||||
/** How much tool detail to show (Settings → "Tool detail"). */
|
||||
enum class ToolDetail {
|
||||
EVERYTHING, // name + full args (collapsible) + output preview
|
||||
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
|
||||
NOTHING, // suppress tool cards entirely
|
||||
EVERYTHING, // name + full args (collapsible) + output preview
|
||||
TRUNCATED, // one-liner "emoji name: preview", collapsible (default)
|
||||
NOTHING, // suppress tool cards entirely
|
||||
;
|
||||
|
||||
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,10 +79,11 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||
|
||||
Box(modifier = Modifier.fillMaxSize().background(theme.background)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Row(
|
||||
@@ -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,58 +359,73 @@ fun SettingsScreen(controller: IrisController, onBack: () -> Unit) {
|
||||
}
|
||||
|
||||
when (pickerTarget) {
|
||||
PickerTarget.UserBubble -> ColorPickerDialog(
|
||||
title = "Your bubble",
|
||||
initialArgb = theme.userBubbleColor,
|
||||
onApply = { controller.setUserBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setUserBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
PickerTarget.AgentBubble -> ColorPickerDialog(
|
||||
title = "Agent bubble",
|
||||
initialArgb = theme.agentBubbleColor,
|
||||
onApply = { controller.setAgentBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setAgentBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
PickerTarget.Background -> ColorPickerDialog(
|
||||
title = "Background",
|
||||
initialArgb = theme.backgroundColor,
|
||||
onApply = { controller.setBackgroundColor(it) },
|
||||
onConfirm = {
|
||||
controller.setBackgroundColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
null -> Unit
|
||||
PickerTarget.UserBubble -> {
|
||||
ColorPickerDialog(
|
||||
title = "Your bubble",
|
||||
initialArgb = theme.userBubbleColor,
|
||||
onApply = { controller.setUserBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setUserBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
PickerTarget.AgentBubble -> {
|
||||
ColorPickerDialog(
|
||||
title = "Agent bubble",
|
||||
initialArgb = theme.agentBubbleColor,
|
||||
onApply = { controller.setAgentBubbleColor(it) },
|
||||
onConfirm = {
|
||||
controller.setAgentBubbleColor(it)
|
||||
pickerTarget = null
|
||||
},
|
||||
onDismiss = { pickerTarget = null },
|
||||
)
|
||||
}
|
||||
|
||||
PickerTarget.Background -> {
|
||||
ColorPickerDialog(
|
||||
title = "Background",
|
||||
initialArgb = theme.backgroundColor,
|
||||
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). */
|
||||
@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 {
|
||||
moveTo(20f * s, 11f * s)
|
||||
lineTo(7.83f * s, 11f * s)
|
||||
lineTo(13.42f * s, 5.41f * s)
|
||||
lineTo(12f * s, 4f * s)
|
||||
lineTo(4f * s, 12f * s)
|
||||
lineTo(12f * s, 20f * s)
|
||||
lineTo(13.41f * s, 18.59f * s)
|
||||
lineTo(7.83f * s, 13f * s)
|
||||
lineTo(20f * s, 13f * s)
|
||||
close()
|
||||
}
|
||||
val path =
|
||||
Path().apply {
|
||||
moveTo(20f * s, 11f * s)
|
||||
lineTo(7.83f * s, 11f * s)
|
||||
lineTo(13.42f * s, 5.41f * s)
|
||||
lineTo(12f * s, 4f * s)
|
||||
lineTo(4f * s, 12f * s)
|
||||
lineTo(12f * s, 20f * s)
|
||||
lineTo(13.41f * s, 18.59f * s)
|
||||
lineTo(7.83f * s, 13f * s)
|
||||
lineTo(20f * s, 13f * s)
|
||||
close()
|
||||
}
|
||||
drawPath(path, color)
|
||||
}
|
||||
}
|
||||
@@ -388,11 +434,12 @@ private fun BackArrow(size: Dp = 22.dp, color: Color = IrisColors.onSurface) {
|
||||
@Composable
|
||||
private fun SettingsCard(content: @Composable () -> Unit) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(IrisColors.panel)
|
||||
.padding(12.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(12.dp))
|
||||
.background(IrisColors.panel)
|
||||
.padding(12.dp),
|
||||
) {
|
||||
content()
|
||||
}
|
||||
@@ -400,14 +447,18 @@ 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
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = onClick),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(32.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color)
|
||||
.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?) {
|
||||
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
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
|
||||
modifier =
|
||||
Modifier
|
||||
.size(44.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(IrisColors.panel)
|
||||
.border(1.dp, IrisColors.divider, RoundedCornerShape(8.dp)),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
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).
|
||||
* [onApply] applies the color and keeps the dialog open for further tweaks;
|
||||
@@ -462,11 +561,12 @@ private fun ColorPickerDialog(
|
||||
text = {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.height(48.dp)
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(color),
|
||||
)
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
Slider(value = hue, onValueChange = { hue = it }, valueRange = 0f..360f)
|
||||
@@ -505,11 +605,12 @@ private fun argbToHsv(argb: Int): FloatArray {
|
||||
val delta = max - min
|
||||
var hue = 0f
|
||||
if (delta != 0f) {
|
||||
hue = when (max) {
|
||||
r -> ((g - b) / delta) % 6f
|
||||
g -> (b - r) / delta + 2f
|
||||
else -> (r - g) / delta + 4f
|
||||
}
|
||||
hue =
|
||||
when (max) {
|
||||
r -> ((g - b) / delta) % 6f
|
||||
g -> (b - r) / delta + 2f
|
||||
else -> (r - g) / delta + 4f
|
||||
}
|
||||
hue *= 60f
|
||||
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). */
|
||||
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,16 +56,17 @@ object IrisColors {
|
||||
val errorText = Color(0xFFFF8A80)
|
||||
}
|
||||
|
||||
private val IrisDark = darkColorScheme(
|
||||
background = IrisColors.background,
|
||||
surface = IrisColors.surface,
|
||||
onBackground = IrisColors.onBackground,
|
||||
onSurface = IrisColors.onSurface,
|
||||
onSurfaceVariant = IrisColors.textDim,
|
||||
primary = IrisColors.primary,
|
||||
onPrimary = Color.White,
|
||||
error = IrisColors.statusRed,
|
||||
)
|
||||
private val IrisDark =
|
||||
darkColorScheme(
|
||||
background = IrisColors.background,
|
||||
surface = IrisColors.surface,
|
||||
onBackground = IrisColors.onBackground,
|
||||
onSurface = IrisColors.onSurface,
|
||||
onSurfaceVariant = IrisColors.textDim,
|
||||
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,9 +64,10 @@ import kotlin.random.Random
|
||||
actual fun MediaFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
var busy by remember { mutableStateOf(false) }
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
Text("Attach a file:", fontSize = 13.sp)
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
@@ -98,9 +100,10 @@ private fun pickFile(): PickedFile? {
|
||||
@Composable
|
||||
actual fun ImageFilePicker(onPicked: (PickedFile?) -> Unit) {
|
||||
Button(onClick = {
|
||||
val chooser = JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
}
|
||||
val chooser =
|
||||
JFileChooser().apply {
|
||||
fileFilter = FileNameExtensionFilter("Images", "jpg", "jpeg", "png", "webp", "bmp", "gif")
|
||||
}
|
||||
if (chooser.showOpenDialog(null) != JFileChooser.APPROVE_OPTION) {
|
||||
onPicked(null)
|
||||
return@Button
|
||||
@@ -151,62 +154,110 @@ 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) {
|
||||
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 {
|
||||
val img = ImageIO.read(File(path)) ?: return@withContext null
|
||||
img.toComposeImageBitmap()
|
||||
} catch (_: Exception) {
|
||||
// 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 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 {
|
||||
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)
|
||||
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()
|
||||
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) {
|
||||
try {
|
||||
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 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()
|
||||
val baos = ByteArrayOutputStream()
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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).
|
||||
@@ -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,11 +340,12 @@ private fun AudioPlayer(media: MediaItem, path: String) {
|
||||
}
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||
Spacer(modifier = Modifier.height(6.dp))
|
||||
@@ -309,14 +372,18 @@ 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
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
modifier =
|
||||
Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(RoundedCornerShape(10.dp))
|
||||
.background(Color(0xFF20242E))
|
||||
.padding(10.dp),
|
||||
) {
|
||||
Text(media.filename, fontSize = 12.sp, maxLines = 1)
|
||||
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
|
||||
* [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,10 +431,15 @@ private class MpvAudioSession(private val path: String) {
|
||||
|
||||
fun start() {
|
||||
try {
|
||||
process = ProcessBuilder(
|
||||
"mpv", "--no-video", "--really-quiet", "--no-terminal",
|
||||
"--input-ipc-server=$socketPath", path,
|
||||
).redirectErrorStream(true).start()
|
||||
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,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 obj = buildJsonObject {
|
||||
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||
}
|
||||
val obj =
|
||||
buildJsonObject {
|
||||
put("command", buildJsonArray { cmd.forEach { add(JsonPrimitive(it)) } })
|
||||
requestId?.let { put("request_id", JsonPrimitive(it)) }
|
||||
}
|
||||
val bytes = (obj.toString() + "\n").toByteArray(Charsets.UTF_8)
|
||||
ch.write(ByteBuffer.wrap(bytes))
|
||||
}
|
||||
@@ -471,15 +564,25 @@ 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,11 +55,12 @@ class DesktopSecureStore : SecureStore {
|
||||
|
||||
private fun migrateLegacy() {
|
||||
if (!legacyFile.exists()) return
|
||||
val legacy = try {
|
||||
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
val legacy =
|
||||
try {
|
||||
IrisJson.instance.decodeFromString(PairingData.serializer(), legacyFile.readText())
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
if (legacy != null) {
|
||||
val s = load()
|
||||
save(
|
||||
@@ -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,32 +332,49 @@ 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 {
|
||||
if (isMac) {
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||
} else {
|
||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||
fun read(): String? =
|
||||
try {
|
||||
if (isMac) {
|
||||
out(listOf("security", "find-generic-password", "-a", "iris", "-s", "iris-gateway-token", "-w"))
|
||||
} else {
|
||||
out(listOf("secret-tool", "lookup", "app", "iris"))
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
|
||||
fun write(value: String) {
|
||||
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,17 +406,26 @@ 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 {
|
||||
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||
val text = p.inputStream.bufferedReader().readText().trim()
|
||||
p.waitFor()
|
||||
text.ifBlank { null }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
private fun which(bin: String): String? =
|
||||
try {
|
||||
val p = ProcessBuilder("which", bin).redirectErrorStream(true).start()
|
||||
val text =
|
||||
p.inputStream
|
||||
.bufferedReader()
|
||||
.readText()
|
||||
.trim()
|
||||
p.waitFor()
|
||||
text.ifBlank { null }
|
||||
} catch (_: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,3 @@
|
||||
package iris.platform
|
||||
|
||||
actual val isDesktop: Boolean = true
|
||||
actual val isDesktop: Boolean = true
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 758 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 269 KiB |
BIN
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 |
Reference in new issue
Block a user