Files
ai-app/app/androidApp/src/main/kotlin/com/example/aiapp/SessionImage.kt
T
2026-09-12 21:52:47 -04:00

471 lines
19 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.interaction.MutableInteractionSource
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.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material3.Button
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableFloatStateOf
import androidx.compose.runtime.mutableIntStateOf
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.draw.clip
import androidx.compose.ui.geometry.Offset
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.layout.onSizeChanged
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntSize
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 androidx.compose.ui.window.DialogWindowProvider
import androidx.core.view.ViewCompat
import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.WindowInsetsControllerCompat
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 -- and in
* a bottom-anchored list, images loading above the viewport moved the text under the reader's eyes.
*
* Four lines of body text, so a screenshot reads as an attachment beside the conversation rather
* than as a page of its own. The full-size view itself is not here: [onOpen] hands the ref to the
* screen, which draws [SessionImageViewer] outside the list.
*/
@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) {
// 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.
null ->
if (failed) {
Text(
"[image $ref unavailable]",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
} else {
LoadingImage(height)
}
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.
*
* Held by the screen, none of that reaches it: what is open is a property of the screen.
*
* The cost is one fetch, since the thumbnail's decoded bitmap belongs to a row this does not go
* through. Paid deliberately: it is one request for a picture somebody asked to see.
*/
@Composable
fun SessionImageViewer(
settings: ServerSettings,
sessionId: String,
ref: String,
onClose: () -> Unit,
) {
val (bitmap, failed) = rememberSessionBitmap(settings, sessionId, ref)
val view = LocalView.current
var hiddenBars by remember(ref) { mutableStateOf(ViewerBars()) }
var barInsets by remember(ref) { mutableStateOf(ViewerBarInsets()) }
Dialog(
onDismissRequest = onClose,
properties =
DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
) {
ViewerSystemBars(hiddenBars)
Box(
Modifier.fillMaxSize()
.background(Color.Black)
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onClose,
),
contentAlignment = Alignment.Center,
) {
when (val image = bitmap) {
// Two states, not one, exactly as the thumbnail has them. 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 ->
if (failed) {
Text(
"Image $ref is unavailable",
color = Color.White,
style = MaterialTheme.typography.bodyMedium,
)
} else {
// The whole dialog is the area this picture is about to fill, so the
// spinner sits in the middle of it. White for the same reason the words
// beside it are.
CircularProgressIndicator(color = Color.White)
}
else -> {
var viewport by remember { mutableStateOf(IntSize.Zero) }
var nativeSizeRequest by remember { mutableIntStateOf(0) }
ZoomableImage(
image,
nativeSizeRequest = nativeSizeRequest,
onViewportChanged = {
viewport = it
ViewCompat.getRootWindowInsets(view)?.let { insets ->
barInsets =
ViewerBarInsets(
status =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.statusBars()
)
.top,
navigation =
insets
.getInsetsIgnoringVisibility(
WindowInsetsCompat.Type.navigationBars()
)
.bottom,
)
}
},
onBarsChanged = { hiddenBars = it },
barInsets = barInsets,
viewport = viewport,
)
Button(
onClick = { nativeSizeRequest++ },
modifier =
Modifier.align(Alignment.BottomEnd)
.navigationBarsPadding()
.padding(16.dp),
) {
Text("100%")
}
}
}
}
}
}
/**
* The room a picture is about to take, with a spinner in the middle of it.
*
* A square of the row's own height rather than the full width of the transcript: the height is what
* [SessionImage] reserves and the width is not known until the bytes arrive, so a full-width
* placeholder would promise a picture wider than most turn out to be.
*
* Tinted, so the reader can see that something is being kept for a picture -- which is also what
* distinguishes it from the failure beside it, words on the ordinary surface.
*/
@Composable
private fun LoadingImage(height: Dp) {
Box(
Modifier.size(height)
.clip(MaterialTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHigh),
contentAlignment = Alignment.Center,
) {
CircularProgressIndicator(Modifier.size(LOADING_SPINNER), strokeWidth = 2.dp)
}
}
/** Small enough to sit inside the thumbnail's square without filling it. */
private val LOADING_SPINNER = 24.dp
/**
* 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 when a hardcoded height is
* 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.
*/
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, with the whole
* image visible without enlarging a smaller one; the 100% control changes to one bitmap pixel per
* screen pixel and recenters it.
*/
@Composable
private fun ZoomableImage(
image: ImageBitmap,
nativeSizeRequest: Int,
onViewportChanged: (IntSize) -> Unit,
onBarsChanged: (ViewerBars) -> Unit,
barInsets: ViewerBarInsets,
viewport: IntSize,
) {
var scale by remember { mutableFloatStateOf(1f) }
var offsetX by remember { mutableFloatStateOf(0f) }
var offsetY by remember { mutableFloatStateOf(0f) }
val nativeScale = nativeScale(image.width, image.height, viewport.width, viewport.height)
LaunchedEffect(nativeSizeRequest, nativeScale) {
if (nativeSizeRequest > 0) {
scale = nativeScale
offsetX = 0f
offsetY = 0f
}
}
val bars =
viewerBars(
image.width,
image.height,
viewport.width,
viewport.height,
scale,
Offset(offsetX, offsetY),
barInsets,
)
SideEffect { onBarsChanged(bars) }
Image(
bitmap = image,
contentDescription = "Attached image",
contentScale = ContentScale.Inside,
// Zoomed in, the reader is looking at pixels on purpose.
filterQuality = FilterQuality.None,
modifier =
Modifier.fillMaxSize()
.onSizeChanged(onViewportChanged)
.pointerInput(nativeScale) {
detectTransformGestures { centroid, pan, zoom, _ ->
val oldScale = scale
val maximumScale = maxOf(8f, nativeScale)
val newScale = (oldScale * zoom).coerceIn(1f, maximumScale)
if (newScale > 1f) {
val offset =
zoomOffset(
Offset(offsetX, offsetY),
centroid,
pan,
oldScale,
newScale,
Offset(size.width / 2f, size.height / 2f),
)
offsetX = offset.x
offsetY = offset.y
} else {
offsetX = 0f
offsetY = 0f
}
scale = newScale
}
}
.graphicsLayer {
scaleX = scale
scaleY = scale
translationX = offsetX
translationY = offsetY
},
)
}
/** Lets the picture use the whole display, hiding only the system bars it actually reaches. */
@Composable
private fun ViewerSystemBars(hidden: ViewerBars) {
val view = LocalView.current
val window = (view.parent as? DialogWindowProvider)?.window
val controller = window?.let { WindowCompat.getInsetsController(it, view) }
SideEffect {
controller?.systemBarsBehavior =
WindowInsetsControllerCompat.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE
if (hidden.status) {
controller?.hide(WindowInsetsCompat.Type.statusBars())
} else {
controller?.show(WindowInsetsCompat.Type.statusBars())
}
if (hidden.navigation) {
controller?.hide(WindowInsetsCompat.Type.navigationBars())
} else {
controller?.show(WindowInsetsCompat.Type.navigationBars())
}
}
DisposableEffect(view) {
onDispose {
controller?.show(
WindowInsetsCompat.Type.statusBars() or WindowInsetsCompat.Type.navigationBars()
)
}
}
}
internal data class ViewerBars(val status: Boolean = false, val navigation: Boolean = false)
internal data class ViewerBarInsets(val status: Int = 0, val navigation: Int = 0)
/** Which full-screen system-bar regions the fitted, zoomed and panned image intersects. */
internal fun viewerBars(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
scale: Float,
offset: Offset,
insets: ViewerBarInsets,
): ViewerBars {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) {
return ViewerBars()
}
val fittedScale = insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
val width = imageWidth * fittedScale * scale
val height = imageHeight * fittedScale * scale
val left = viewportWidth / 2f + offset.x - width / 2f
val right = left + width
val top = viewportHeight / 2f + offset.y - height / 2f
val bottom = top + height
val crossesScreen = right > 0f && left < viewportWidth
return ViewerBars(
status = crossesScreen && insets.status > 0 && bottom > 0f && top < insets.status,
navigation =
crossesScreen &&
insets.navigation > 0 &&
bottom > viewportHeight - insets.navigation &&
top < viewportHeight,
)
}
/** Scale relative to [ContentScale.Inside] at which bitmap and screen pixels are one-to-one. */
internal fun nativeScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float {
if (imageWidth <= 0 || imageHeight <= 0 || viewportWidth <= 0 || viewportHeight <= 0) return 1f
return 1f / insideScale(imageWidth, imageHeight, viewportWidth, viewportHeight)
}
/** The downscale-only factor used by [ContentScale.Inside]. */
private fun insideScale(
imageWidth: Int,
imageHeight: Int,
viewportWidth: Int,
viewportHeight: Int,
): Float =
minOf(
1f,
viewportWidth.toFloat() / imageWidth,
viewportHeight.toFloat() / imageHeight,
)
/** Keeps the image point beneath [centroid] beneath the fingers as its scale changes. */
internal fun zoomOffset(
offset: Offset,
centroid: Offset,
pan: Offset,
oldScale: Float,
newScale: Float,
viewportCenter: Offset,
): Offset {
val scaleChange = newScale / oldScale
return offset * scaleChange + (centroid - viewportCenter) * (1f - scaleChange) + pan
}