A `Read` that returns an image is a row of one call, and the moment the session makes its next call the two become a group -- which is a different composable in a different part of the tree, so the old subtree goes and everything it remembered goes with it. The full-screen viewer was inside that subtree, so somebody looking at a screenshot was thrown back to the transcript because the session carried on working. A page of history landing does the same thing to the same row. What is open is a property of the screen rather than of whichever row happened to draw the thumbnail, so it is held there now and drawn beside the other two dialogs. Nothing that happens to rows can reach it. The cost is one fetch when it opens, since the thumbnail's decoded bitmap belongs to a row this no longer goes through. Paid deliberately rather than plumbed around: it is one request for a picture somebody asked to see, and the viewer draws the same two empty states the thumbnail does -- still coming, and never coming -- which it previously could not have, since it only ever opened on a bitmap already in hand. `/tools n gap` now puts a screenshot on its first call, so the case is reproducible rather than argued about: that command already existed to make a run *grow* while somebody watches, and the image is what made growing matter. Checked on the emulator with `/tools 3 30` -- opened the image on the lone call, and it was still open a minute later with the row by then inside a group of three, and back returned to the transcript rather than leaving the app.
235 lines
10 KiB
Kotlin
235 lines
10 KiB
Kotlin
package com.example.aiapp
|
|
|
|
import android.graphics.BitmapFactory
|
|
import androidx.compose.foundation.Image
|
|
import androidx.compose.foundation.background
|
|
import androidx.compose.foundation.clickable
|
|
import androidx.compose.foundation.gestures.detectTransformGestures
|
|
import androidx.compose.foundation.layout.Box
|
|
import androidx.compose.foundation.layout.fillMaxSize
|
|
import androidx.compose.foundation.layout.fillMaxWidth
|
|
import androidx.compose.foundation.layout.height
|
|
import androidx.compose.material3.MaterialTheme
|
|
import androidx.compose.material3.Text
|
|
import androidx.compose.runtime.Composable
|
|
import androidx.compose.runtime.LaunchedEffect
|
|
import androidx.compose.runtime.getValue
|
|
import androidx.compose.runtime.mutableFloatStateOf
|
|
import androidx.compose.runtime.mutableStateOf
|
|
import androidx.compose.runtime.remember
|
|
import androidx.compose.runtime.setValue
|
|
import androidx.compose.ui.Alignment
|
|
import androidx.compose.ui.Modifier
|
|
import androidx.compose.ui.graphics.Color
|
|
import androidx.compose.ui.graphics.FilterQuality
|
|
import androidx.compose.ui.graphics.ImageBitmap
|
|
import androidx.compose.ui.graphics.asImageBitmap
|
|
import androidx.compose.ui.graphics.graphicsLayer
|
|
import androidx.compose.ui.input.pointer.pointerInput
|
|
import androidx.compose.ui.layout.ContentScale
|
|
import androidx.compose.ui.platform.LocalDensity
|
|
import androidx.compose.ui.unit.Dp
|
|
import androidx.compose.ui.unit.dp
|
|
import androidx.compose.ui.unit.isSpecified
|
|
import androidx.compose.ui.window.Dialog
|
|
import androidx.compose.ui.window.DialogProperties
|
|
import kotlinx.coroutines.Dispatchers
|
|
import kotlinx.coroutines.withContext
|
|
|
|
/**
|
|
* One image from the session's files route: the bitmap once it arrives, and whether it never will.
|
|
*
|
|
* [failed] exists because the two empty states differ in kind -- still coming and never coming --
|
|
* and a reader can act on the second; each caller supplies its own words for them.
|
|
*/
|
|
data class SessionBitmap(val bitmap: ImageBitmap?, val failed: Boolean)
|
|
|
|
/**
|
|
* Fetches (authenticated, pinned) and decodes one transcript image, remembered per ref so scrolling
|
|
* does not refetch.
|
|
*
|
|
* Shared by the transcript's images and the composer's pending attachments, because the fetch, the
|
|
* decode and the two-state answer are one block of logic that had been written twice.
|
|
*/
|
|
@Composable
|
|
fun rememberSessionBitmap(settings: ServerSettings, sessionId: String, ref: String): SessionBitmap {
|
|
var state by remember(ref) { mutableStateOf(SessionBitmap(null, failed = false)) }
|
|
LaunchedEffect(ref) {
|
|
state =
|
|
try {
|
|
val bytes =
|
|
withContext(Dispatchers.IO) { fetchSessionFile(settings, sessionId, ref) }
|
|
val decoded = BitmapFactory.decodeByteArray(bytes, 0, bytes.size)?.asImageBitmap()
|
|
SessionBitmap(decoded, failed = decoded == null)
|
|
} catch (_: ApiException) {
|
|
SessionBitmap(null, failed = true)
|
|
}
|
|
}
|
|
return state
|
|
}
|
|
|
|
/**
|
|
* An image in the transcript: a fixed-height thumbnail that opens full screen.
|
|
*
|
|
* The height is decided before the bytes arrive and never changes. An image row that grew when it
|
|
* finished loading pushed everything below it, so a transcript being read scrolled itself while
|
|
* somebody was looking at it -- and in a bottom-anchored list, images loading above the viewport
|
|
* moved the text under the reader's eyes. Reserving the final height makes loading invisible, which
|
|
* is what it should be.
|
|
*
|
|
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
|
|
* than as a page of its own. Full size is one tap away -- but the full-size view itself is not
|
|
* here. [onOpen] hands the ref to the screen, which draws [SessionImageViewer] outside the list;
|
|
* see that function for the reason.
|
|
*/
|
|
@Composable
|
|
fun SessionImage(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
ref: String,
|
|
onOpen: (String) -> Unit,
|
|
) {
|
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
val height = thumbnailHeight()
|
|
val heightPx = with(LocalDensity.current) { height.roundToPx() }
|
|
Box(Modifier.fillMaxWidth().height(height), contentAlignment = Alignment.CenterStart) {
|
|
when (val image = bitmap) {
|
|
null ->
|
|
Text(
|
|
// Two states, not one: an image still arriving and an image that will never
|
|
// arrive look nothing alike to a reader who can do something about the second.
|
|
if (failed) "[image $ref unavailable]" else "[loading image…]",
|
|
style = MaterialTheme.typography.bodySmall,
|
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
)
|
|
else ->
|
|
Image(
|
|
bitmap = image,
|
|
contentDescription = "Attached image, tap to view full screen",
|
|
contentScale = ContentScale.Fit,
|
|
filterQuality = enlargingFilter(image.height, heightPx),
|
|
modifier = Modifier.fillMaxSize().clickable { onOpen(ref) },
|
|
alignment = Alignment.CenterStart,
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The image somebody opened, drawn by the screen rather than by the row it was tapped in.
|
|
*
|
|
* The row is the wrong place to hold this, and it took a real fault to see why: an image from a
|
|
* `Read` on its own is a row of one call, and the moment the next call arrives the two become a
|
|
* group -- a different composable in a different part of the tree, so everything the old subtree
|
|
* remembered goes, the dialog included. Somebody looking at a screenshot was thrown back to the
|
|
* transcript because the session made another tool call. The same happens to a row regrouped by a
|
|
* page of history landing.
|
|
*
|
|
* Held by the screen, none of that reaches it: what is open is a property of the screen, not of
|
|
* whichever row happened to draw the thumbnail.
|
|
*
|
|
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
|
|
* through. Paid deliberately rather than plumbed around: it is one request for a picture somebody
|
|
* asked to see, and the loading and unavailable states below are the same two the thumbnail draws.
|
|
*/
|
|
@Composable
|
|
fun SessionImageViewer(
|
|
settings: ServerSettings,
|
|
sessionId: String,
|
|
ref: String,
|
|
onClose: () -> Unit,
|
|
) {
|
|
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
|
|
Dialog(
|
|
onDismissRequest = onClose,
|
|
properties = DialogProperties(usePlatformDefaultWidth = false),
|
|
) {
|
|
Box(
|
|
Modifier.fillMaxSize().background(Color.Black).clickable(onClick = onClose),
|
|
contentAlignment = Alignment.Center,
|
|
) {
|
|
when (val image = bitmap) {
|
|
// Two states, not one, exactly as the thumbnail has them: still coming, and never
|
|
// coming. Stated in white because this box paints its own black behind them and a
|
|
// theme colour would be picked against a surface that is not there.
|
|
null ->
|
|
Text(
|
|
if (failed) "Image $ref is unavailable" else "Loading image…",
|
|
color = Color.White,
|
|
style = MaterialTheme.typography.bodyMedium,
|
|
)
|
|
else -> ZoomableImage(image)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Four lines of the body style the transcript is set in.
|
|
*
|
|
* Measured from the type rather than written as a dp, so it stays four lines when the text size
|
|
* changes -- including when the reader has scaled fonts up, which is exactly when a hardcoded
|
|
* height would be wrong.
|
|
*/
|
|
@Composable
|
|
private fun thumbnailHeight(): Dp {
|
|
val line = MaterialTheme.typography.bodyLarge.lineHeight
|
|
val density = LocalDensity.current
|
|
return remember(line, density) {
|
|
with(density) { if (line.isSpecified) (line * 4).toDp() else 96.dp }
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Nearest neighbour when the image is being enlarged, smooth when it is being shrunk.
|
|
*
|
|
* A small image blown up with interpolation turns into a blur that hides what it is -- the same
|
|
* image with hard pixel edges stays readable. Shrinking wants the opposite, so this is a decision
|
|
* per image rather than a preference set once.
|
|
*/
|
|
private fun enlargingFilter(sourceHeight: Int, drawnHeight: Int): FilterQuality =
|
|
if (sourceHeight < drawnHeight) FilterQuality.None else FilterQuality.High
|
|
|
|
/**
|
|
* The image on its own, as large as it fits, with pinch to zoom.
|
|
*
|
|
* Inside a dialog rather than a screen -- see [SessionImageViewer] -- so the platform's back
|
|
* gesture returns to the transcript instead of leaving the app. It opens fitted, the whole image
|
|
* visible, which is the thing a reader wants first; zoom is theirs from there.
|
|
*/
|
|
@Composable
|
|
private fun ZoomableImage(image: ImageBitmap) {
|
|
var scale by remember { mutableFloatStateOf(1f) }
|
|
var offsetX by remember { mutableFloatStateOf(0f) }
|
|
var offsetY by remember { mutableFloatStateOf(0f) }
|
|
Image(
|
|
bitmap = image,
|
|
contentDescription = "Attached image",
|
|
contentScale = ContentScale.Fit,
|
|
// Zoomed in, the reader is looking at pixels on purpose.
|
|
filterQuality = FilterQuality.None,
|
|
modifier =
|
|
Modifier.fillMaxSize()
|
|
.pointerInput(Unit) {
|
|
detectTransformGestures { _, pan, zoom, _ ->
|
|
// Floor of 1 so the image cannot be pinched smaller than fitted, which is
|
|
// already the whole of it; a ceiling so it cannot be lost off-screen.
|
|
scale = (scale * zoom).coerceIn(1f, 8f)
|
|
if (scale > 1f) {
|
|
offsetX += pan.x
|
|
offsetY += pan.y
|
|
} else {
|
|
offsetX = 0f
|
|
offsetY = 0f
|
|
}
|
|
}
|
|
}
|
|
.graphicsLayer {
|
|
scaleX = scale
|
|
scaleY = scale
|
|
translationX = offsetX
|
|
translationY = offsetY
|
|
},
|
|
)
|
|
}
|