Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 60 additions & 3 deletions app/src/main/kotlin/com/gemini/app/ui/chat/ChatScreen.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
}
Expand Down Expand Up @@ -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<String>) {
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)
Comment on lines +1090 to +1099

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (performance): Bitmap decoding is happening synchronously on the main thread, which can cause jank for large or multiple images.

decodeThumbnail(path) is run inside remember { ... }, so both the bounds check and BitmapFactory.decodeFile execute on the main thread during composition. Decoding several large (20MB+) images this way can stall the UI. Move decoding to a background dispatcher (e.g. produceState / LaunchedEffect with withContext(Dispatchers.IO)) or use an image-loading library that manages threading and caching for you.

.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()
}
35 changes: 31 additions & 4 deletions app/src/main/kotlin/com/gemini/app/ui/chat/ChatViewModel.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}
Expand All @@ -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"
Comment on lines +269 to +278

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Persisted attachment files under filesDir/attachments are never cleaned up and may accumulate indefinitely.

Because these files are never deleted, internal storage can grow without bound, particularly for users who send many large images. Consider using cacheDir so the system can reclaim space, or implement an explicit cleanup strategy (e.g., tie deletion to message removal, cap the directory size, or periodically delete old attachments).

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
Expand Down Expand Up @@ -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()
Expand Down
121 changes: 105 additions & 16 deletions app/src/main/kotlin/com/gemini/app/ui/chat/MarkdownText.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Comment on lines +328 to +330

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): For non-http(s) links (mailto:, tel:, custom app schemes), the normalization prepends https://, which breaks those URIs.

The normalization currently assumes anything not starting with http:// or https:// is a bare host and prepends https://, so mailto:foo@bar.com becomes https://mailto:foo@bar.com. Instead, distinguish between URLs with and without a scheme (e.g., Uri.parse(url).scheme == null or a host-style regex) and only prepend https:// when there is no scheme, leaving mailto:, tel:, and custom schemes unchanged.

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
Expand Down Expand Up @@ -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++ }
}
Expand All @@ -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))
Expand Down
Loading
Loading