diff --git a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt index 801de30..0cf86fc 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt @@ -96,6 +96,8 @@ import com.gemini.domain.GeminiMessage import com.gemini.domain.MessageRole import com.gemini.ui.LocalGeminiColors import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.layout.ContentScale import kotlinx.coroutines.launch @OptIn(ExperimentalMaterial3Api::class) @@ -150,8 +152,11 @@ fun ChatScreen( } // Only auto-scroll when the user is already near the bottom — otherwise - // they get yanked out of whatever they were reading. - LaunchedEffect(messages.size, thinking, error) { + // they get yanked out of whatever they were reading. The streamed text of + // the last message grows without changing `messages.size`, so include it + // as a key to keep the view pinned to the bottom during streaming. + val tailText = messages.lastOrNull()?.text + LaunchedEffect(messages.size, tailText, thinking, error) { if (isNearBottom) { val target = messages.size - 1 + extraTail(thinking, error) if (target >= 0) listState.animateScrollToItem(target) @@ -573,7 +578,21 @@ fun MessageBubble( ) ) { if (message.isUser) { - Text(message.text, color = content) + Column { + if (message.attachmentPaths.isNotEmpty()) { + AttachmentThumbnails(message.attachmentPaths) + Spacer(Modifier.height(6.dp)) + } + val visibleText = if (message.attachmentPaths.isNotEmpty() && + message.text.startsWith("📎 ")) { + // Drop the "📎 image (png, 128KB)\n" prefix added by + // RestGeminiCore — the thumbnail already conveys it. + message.text.substringAfter('\n', "") + } else message.text + if (visibleText.isNotBlank()) { + Text(visibleText, color = content) + } + } } else { MarkdownText(text = message.text, color = content) } @@ -1062,3 +1081,41 @@ private fun formatBytes(size: Int): String = when { size >= 1_000 -> "${size / 1_000} KB" else -> "$size B" } + +// Small thumbnails of sent image attachments. Decodes each file once via a +// sampled BitmapFactory to keep memory low (targets ~320 px). If the file no +// longer exists (cache cleared, sideload restore), the slot is skipped. +@Composable +private fun AttachmentThumbnails(paths: List) { + Row(horizontalArrangement = Arrangement.spacedBy(6.dp)) { + paths.forEach { path -> + val bitmap = remember(path) { decodeThumbnail(path) } + if (bitmap != null) { + Image( + bitmap = bitmap, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(120.dp) + .background( + color = MaterialTheme.colorScheme.surfaceVariant, + shape = MaterialTheme.shapes.small + ) + ) + } + } + } +} + +private fun decodeThumbnail(path: String): androidx.compose.ui.graphics.ImageBitmap? { + val file = java.io.File(path) + if (!file.exists()) return null + val opts = android.graphics.BitmapFactory.Options().apply { inJustDecodeBounds = true } + android.graphics.BitmapFactory.decodeFile(path, opts) + val target = 320 + var sample = 1 + while (opts.outWidth / sample > target && opts.outHeight / sample > target) sample *= 2 + val load = android.graphics.BitmapFactory.Options().apply { inSampleSize = sample } + val bmp = android.graphics.BitmapFactory.decodeFile(path, load) ?: return null + return bmp.asImageBitmap() +} diff --git a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt index 35454b7..b18b37f 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt @@ -188,7 +188,7 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { if (text.isBlank() && attachments.isEmpty()) return if (sendJob?.isActive == true) return lastUserPrompt = text - val payload = attachments.map { Attachment(it.bytes, it.mimeType) } + val payload = attachments.map { Attachment(it.bytes, it.mimeType, it.localPath) } _pendingAttachments.value = emptyList() sendJob = viewModelScope.launch { _isLoading.value = true @@ -238,12 +238,15 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { ?: return@runCatching null val displayName = queryDisplayName(context, uri) ?: "image.${mime.substringAfter('/').take(4)}" + val id = "att-${System.nanoTime()}" + val localPath = persistAttachment(context, id, bytes, mime) PendingAttachment( - id = "att-${System.nanoTime()}", + id = id, bytes = bytes, mimeType = mime, displayName = displayName.take(40), - sizeBytes = bytes.size + sizeBytes = bytes.size, + localPath = localPath ) }.getOrNull() } @@ -260,6 +263,29 @@ class ChatViewModel(private val core: RestGeminiCore) : ViewModel() { }.getOrNull() } + // Copy the picked image into app-owned cache so the chat bubble can show a + // thumbnail without holding an Android content:// permission that may be + // revoked, and so reloads from ChatStore can still find the file. + private fun persistAttachment( + context: Context, + id: String, + bytes: ByteArray, + mime: String + ): String? = runCatching { + val ext = when { + mime.contains("png") -> "png" + mime.contains("webp") -> "webp" + mime.contains("gif") -> "gif" + mime.contains("heic") -> "heic" + mime.contains("heif") -> "heif" + else -> "jpg" + } + val dir = java.io.File(context.filesDir, "attachments").also { it.mkdirs() } + val file = java.io.File(dir, "$id.$ext") + file.writeBytes(bytes) + file.absolutePath + }.getOrNull() + private fun maybeAutoCompress() { if (!_autoCompressEnabled.value) return if (_compressing.value) return @@ -410,7 +436,8 @@ data class PendingAttachment( val bytes: ByteArray, val mimeType: String, val displayName: String, - val sizeBytes: Int + val sizeBytes: Int, + val localPath: String? = null ) { override fun equals(other: Any?) = other is PendingAttachment && id == other.id override fun hashCode() = id.hashCode() diff --git a/app/src/main/kotlin/com/gemini/app/ui/chat/MarkdownText.kt b/app/src/main/kotlin/com/gemini/app/ui/chat/MarkdownText.kt index 228912f..8107351 100644 --- a/app/src/main/kotlin/com/gemini/app/ui/chat/MarkdownText.kt +++ b/app/src/main/kotlin/com/gemini/app/ui/chat/MarkdownText.kt @@ -3,6 +3,8 @@ package com.gemini.app.ui.chat import android.content.ClipData import android.content.ClipboardManager import android.content.Context +import android.content.Intent +import android.net.Uri import android.widget.Toast import androidx.compose.foundation.border import androidx.compose.foundation.horizontalScroll @@ -16,10 +18,12 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.width import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.ClickableText import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ContentCopy import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalTextStyle import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text @@ -29,8 +33,10 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.AnnotatedString import androidx.compose.ui.text.SpanStyle +import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.withStyle @@ -135,19 +141,19 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co } } when { - trimmed.startsWith("### ") -> Text( + trimmed.startsWith("### ") -> ProseText( annotateInline(trimmed.removePrefix("### "), baseColor), style = MaterialTheme.typography.titleSmall, color = baseColor, modifier = Modifier.padding(top = 2.dp, bottom = 1.dp) ) - trimmed.startsWith("## ") -> Text( + trimmed.startsWith("## ") -> ProseText( annotateInline(trimmed.removePrefix("## "), baseColor), style = MaterialTheme.typography.titleMedium, color = baseColor, modifier = Modifier.padding(top = 4.dp, bottom = 2.dp) ) - trimmed.startsWith("# ") -> Text( + trimmed.startsWith("# ") -> ProseText( annotateInline(trimmed.removePrefix("# "), baseColor), style = MaterialTheme.typography.titleLarge, color = baseColor, @@ -159,7 +165,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co modifier = Modifier.width(3.dp).height(20.dp) ) {} Spacer(Modifier.width(8.dp)) - Text( + ProseText( annotateInline(trimmed.removePrefix("> "), baseColor), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.bodyMedium @@ -175,7 +181,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co val label = trimmed.removeRange(0, 6) Row(verticalAlignment = Alignment.CenterVertically) { Text(if (checked) "☑ " else "☐ ", color = baseColor) - Text( + ProseText( annotateInline(label, baseColor), color = baseColor, style = MaterialTheme.typography.bodyMedium @@ -187,7 +193,7 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co horizontalArrangement = Arrangement.Start ) { Text("• ", color = baseColor) - Text( + ProseText( annotateInline(trimmed.drop(2), baseColor), color = baseColor, style = MaterialTheme.typography.bodyMedium @@ -198,18 +204,18 @@ private fun renderProse(body: String, baseColor: androidx.compose.ui.graphics.Co if (m != null) { Row { Text("${m.groupValues[1]}. ", color = baseColor) - Text( + ProseText( annotateInline(m.groupValues[2], baseColor), color = baseColor, style = MaterialTheme.typography.bodyMedium ) } } else { - Text(annotateInline(raw, baseColor), color = baseColor) + ProseText(annotateInline(raw, baseColor), color = baseColor) } } trimmed.isEmpty() -> Spacer(Modifier.padding(vertical = 3.dp)) - else -> Text( + else -> ProseText( annotateInline(raw, baseColor), color = baseColor, style = MaterialTheme.typography.bodyMedium @@ -278,7 +284,7 @@ private fun RowScope.TableCell( header: Boolean, last: Boolean ) { - Text( + ProseText( annotateInline(text, baseColor), style = if (header) MaterialTheme.typography.labelMedium else MaterialTheme.typography.bodySmall, @@ -289,6 +295,48 @@ private fun RowScope.TableCell( ) } +// Text with clickable URL annotations (`URL_TAG`). On tap, launches ACTION_VIEW +// for the URL under the touch point. Falls back to a plain Text when the string +// has no URL annotations so regular prose doesn't eat long-press / selection +// gestures unnecessarily. +@Composable +private fun ProseText( + text: AnnotatedString, + modifier: Modifier = Modifier, + style: TextStyle = LocalTextStyle.current, + color: androidx.compose.ui.graphics.Color = androidx.compose.ui.graphics.Color.Unspecified +) { + val hasLinks = text.getStringAnnotations(URL_TAG, 0, text.length).isNotEmpty() + if (!hasLinks) { + Text(text, modifier = modifier, style = style, color = color) + return + } + val context = LocalContext.current + val effectiveStyle = if (color == androidx.compose.ui.graphics.Color.Unspecified) style + else style.copy(color = color) + ClickableText( + text = text, + modifier = modifier, + style = effectiveStyle, + onClick = { offset -> + text.getStringAnnotations(URL_TAG, offset, offset).firstOrNull() + ?.let { ann -> openUrl(context, ann.item) } + } + ) +} + +private fun openUrl(context: Context, url: String) { + runCatching { + val normalised = if (url.startsWith("http://") || url.startsWith("https://")) url + else "https://$url" + val intent = Intent(Intent.ACTION_VIEW, Uri.parse(normalised)) + .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(intent) + }.onFailure { + Toast.makeText(context, "Cannot open link: $url", Toast.LENGTH_SHORT).show() + } +} + private sealed interface Segment { data class Text(val body: String) : Segment data class Code(val language: String?, val body: String) : Segment @@ -354,10 +402,9 @@ private fun annotateInline(line: String, baseColor: androidx.compose.ui.graphics c == '_' || (c == '*' && (i == 0 || line[i - 1] != '*')) -> { val end = line.indexOf(c, i + 1) if (end > i) { - withStyle( - SpanStyle(fontWeight = FontWeight.Normal, - textDecoration = TextDecoration.None) - ) { append(line.substring(i + 1, end)) } + withStyle(SpanStyle(fontStyle = FontStyle.Italic)) { + append(line.substring(i + 1, end)) + } i = end + 1 } else { append(c); i++ } } @@ -366,21 +413,63 @@ private fun annotateInline(line: String, baseColor: androidx.compose.ui.graphics val paren = if (close > 0 && close + 1 < line.length && line[close + 1] == '(') line.indexOf(')', close + 2) else -1 if (close > 0 && paren > 0) { + val label = line.substring(i + 1, close) + val url = line.substring(close + 2, paren) + pushStringAnnotation(tag = URL_TAG, annotation = url) withStyle( SpanStyle( - color = baseColor, + color = MaterialThemeLink, textDecoration = TextDecoration.Underline ) - ) { append(line.substring(i + 1, close)) } + ) { append(label) } + pop() i = paren + 1 } else { append(c); i++ } } + // Bare URLs (http://, https://). + c == 'h' && line.startsWith("http", i) && run { + val rest = line.substring(i) + rest.startsWith("http://") || rest.startsWith("https://") + } -> { + val end = findUrlEnd(line, i) + val url = line.substring(i, end) + pushStringAnnotation(tag = URL_TAG, annotation = url) + withStyle( + SpanStyle( + color = MaterialThemeLink, + textDecoration = TextDecoration.Underline + ) + ) { append(url) } + pop() + i = end + } else -> { append(c); i++ } } } } } +private fun findUrlEnd(line: String, start: Int): Int { + var j = start + while (j < line.length) { + val ch = line[j] + // Break on whitespace, closing brackets, and trailing punctuation that + // is almost never part of a URL. + if (ch.isWhitespace() || ch in ")]>\"'`") break + j++ + } + // Strip trailing . , ; : ! ? — common sentence punctuation right after URLs. + while (j > start + 1 && line[j - 1] in ".,;:!?") j-- + return j +} + +private const val URL_TAG = "URL" + +// Link colour — kept as a top-level constant so annotateInline stays pure (no +// @Composable context needed). Matches Material 3 primary on both themes +// reasonably well; the underline decoration carries most of the affordance. +private val MaterialThemeLink = androidx.compose.ui.graphics.Color(0xFF4285F4) + private fun copyToClipboard(context: Context, text: String) { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager cm.setPrimaryClip(ClipData.newPlainText("code", text)) diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt index ee4cbac..43f388d 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/RestGeminiCore.kt @@ -51,10 +51,21 @@ import java.util.concurrent.ConcurrentHashMap * Raw attachment bytes + MIME type for a multimodal user turn. Gemini accepts * inlineData parts of up to ~20 MB per request. */ -data class Attachment(val bytes: ByteArray, val mimeType: String) { +data class Attachment( + val bytes: ByteArray, + val mimeType: String, + // Optional local file the UI can render as a thumbnail. Not used by the + // REST call itself (which always sends `bytes` as base64 inlineData). + val localPath: String? = null +) { override fun equals(other: Any?) = other is Attachment && - mimeType == other.mimeType && bytes.contentEquals(other.bytes) - override fun hashCode() = 31 * mimeType.hashCode() + bytes.contentHashCode() + mimeType == other.mimeType && bytes.contentEquals(other.bytes) && + localPath == other.localPath + override fun hashCode(): Int { + var h = 31 * mimeType.hashCode() + bytes.contentHashCode() + h = 31 * h + (localPath?.hashCode() ?: 0) + return h + } } /** @@ -289,7 +300,8 @@ class RestGeminiCore( text = bubbleText, isUser = true, timestamp = System.currentTimeMillis(), - role = MessageRole.USER + role = MessageRole.USER, + attachmentPaths = attachments.mapNotNull { it.localPath } ) ) diff --git a/core-bridge/src/main/kotlin/com/gemini/bridge/storage/ChatStore.kt b/core-bridge/src/main/kotlin/com/gemini/bridge/storage/ChatStore.kt index a663845..cb8b497 100644 --- a/core-bridge/src/main/kotlin/com/gemini/bridge/storage/ChatStore.kt +++ b/core-bridge/src/main/kotlin/com/gemini/bridge/storage/ChatStore.kt @@ -134,6 +134,12 @@ class ChatStore(context: Context) { .put("output", tr.output) ) } + if (m.attachmentPaths.isNotEmpty()) { + json.put( + "attachmentPaths", + JSONArray().apply { m.attachmentPaths.forEach { put(it) } } + ) + } return json } @@ -156,6 +162,9 @@ class ChatStore(context: Context) { output = tr.optString("output") ) } + val attachmentPaths = o.optJSONArray("attachmentPaths")?.let { arr -> + (0 until arr.length()).map { arr.optString(it) }.filter { it.isNotBlank() } + } ?: emptyList() return GeminiMessage( id = id, text = o.optString("text"), @@ -163,7 +172,8 @@ class ChatStore(context: Context) { timestamp = o.optLong("timestamp", System.currentTimeMillis()), role = role, toolCall = toolCall, - toolResult = toolResult + toolResult = toolResult, + attachmentPaths = attachmentPaths ) } diff --git a/domain/src/main/kotlin/com/gemini/domain/GeminiCore.kt b/domain/src/main/kotlin/com/gemini/domain/GeminiCore.kt index 54a3030..ddf8310 100644 --- a/domain/src/main/kotlin/com/gemini/domain/GeminiCore.kt +++ b/domain/src/main/kotlin/com/gemini/domain/GeminiCore.kt @@ -33,5 +33,8 @@ data class GeminiMessage( val timestamp: Long, val role: MessageRole = if (isUser) MessageRole.USER else MessageRole.MODEL, val toolCall: ToolCall? = null, - val toolResult: ToolCallResult? = null + val toolResult: ToolCallResult? = null, + // Local file paths for attachments rendered as thumbnails in the bubble. + // Empty for messages without attachments. + val attachmentPaths: List = emptyList() )