1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8import androidx.compose.foundation.gestures.detectVerticalDragGestures
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.navigationBars
16import androidx.compose.foundation.layout.padding
17import androidx.compose.foundation.layout.statusBars
18import androidx.compose.foundation.layout.windowInsetsPadding
19import androidx.compose.material3.CircularProgressIndicator
20import androidx.compose.material3.Icon
21import androidx.compose.material3.MaterialTheme
22import androidx.compose.material3.Text
23import androidx.compose.runtime.Composable
24import androidx.compose.runtime.Immutable
25import androidx.compose.runtime.getValue
26import androidx.compose.runtime.mutableFloatStateOf
27import androidx.compose.runtime.mutableIntStateOf
28import androidx.compose.runtime.mutableStateOf
29import androidx.compose.runtime.produceState
30import androidx.compose.runtime.remember
31import androidx.compose.runtime.rememberCoroutineScope
32import androidx.compose.runtime.setValue
33import androidx.compose.ui.Alignment
34import androidx.compose.ui.Modifier
35import androidx.compose.ui.draw.clip
36import androidx.compose.ui.graphics.graphicsLayer
37import androidx.compose.ui.input.pointer.pointerInput
38import androidx.compose.ui.layout.ContentScale
39import androidx.compose.ui.platform.LocalContext
40import androidx.compose.ui.res.stringResource
41import androidx.compose.ui.semantics.Role
42import androidx.compose.ui.semantics.contentDescription
43import androidx.compose.ui.semantics.invisibleToUser
44import androidx.compose.ui.semantics.semantics
45import androidx.compose.ui.text.style.TextOverflow
46import androidx.compose.ui.unit.dp
47import androidx.compose.ui.window.Dialog
48import androidx.compose.ui.window.DialogProperties
49import coil3.compose.SubcomposeAsyncImage
50import coil3.network.NetworkHeaders
51import coil3.network.httpHeaders
52import coil3.request.ImageRequest
53import coil3.request.crossfade
55import kotlinx.coroutines.Dispatchers
56import kotlinx.coroutines.launch
57import kotlin.math.absoluteValue
58import androidx.compose.animation.core.Animatable
59import kotlinx.coroutines.withContext
60import androidx.compose.material.icons.Icons
61import androidx.compose.material.icons.filled.BrokenImage
62import androidx.compose.material.icons.filled.Close
63import androidx.compose.material.icons.filled.MoreVert
64import soy.iko.opencode.R
65import soy.iko.opencode.data.model.FilePart
66import soy.iko.opencode.data.model.ServerProfile
67import soy.iko.opencode.data.model.sourcePath
68import soy.iko.opencode.data.network.CertPinUtil
69import soy.iko.opencode.data.network.HttpClientFactory
70import soy.iko.opencode.data.network.NetworkConfig
71import soy.iko.opencode.util.runCatchingCancellable
74 * Carries the bits needed to load an image off the opencode server: the base URL (for
75 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
78data class ImageLoadContext(
80 val basicAuthHeader: String?,
83/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
84fun ServerProfile.toImageContext(): ImageLoadContext {
85 val auth = if (hasAuth && !password.isNullOrEmpty()) {
86 val raw = "$username:$password".toByteArray()
87 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
91 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
92 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
93 // header attached — over cleartext http even when the stored URL is http://. This mirrors
94 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
95 return ImageLoadContext(baseUrl = CertPinUtil.effectiveBaseUrl(this), basicAuthHeader = auth)
98/** True if the part references a raster/vector image we can render. */
99val FilePart.isImage: Boolean
100 get() = mime?.startsWith("image/") == true
102private fun decodeDataUri(data: String): ByteArray? {
103 val comma = data.indexOf(',')
104 if (comma < 0) return null
105 val header = data.substring(0, comma)
106 val payload = data.substring(comma + 1)
107 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
108 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
109 // which would throw and render the broken-image icon.
111 if (header.contains(";base64", ignoreCase = true)) {
112 Base64.decode(payload, Base64.DEFAULT)
114 android.net.Uri.decode(payload).toByteArray()
120 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
121 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
123private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
125 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
126 val url = url ?: return null
127 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
128 if (url.startsWith("data:")) return decodeDataUri(url)
129 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
130 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
131 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
132 // otherwise get the server's Basic auth attached and leak the credentials to that host.
133 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
134 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
135 val resolved = runCatching {
136 java.net.URI(base).resolve(url).normalize()
137 }.getOrElse { return null }
138 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
139 // sending the request (with its Basic auth) to another host. A server-absolute path
140 // resolves to a different base *path* but the same origin, so it must still be allowed.
141 // Compare *effective* ports (substituting the scheme default when unspecified) so an
142 // absolute image URL that spells out the default port — "https://host:443/x.png" while
143 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
144 // when omitted, so a raw `==` would treat 443 and -1 as different).
145 val sameOrigin = resolved.host != null &&
146 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
147 resolved.host.equals(baseUri.host, ignoreCase = true) &&
148 effectivePort(resolved) == effectivePort(baseUri)
149 return if (sameOrigin) resolved.toString() else null
152/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
153private fun effectivePort(uri: java.net.URI): Int {
154 if (uri.port != -1) return uri.port
155 return when (uri.scheme?.lowercase()) {
163 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
164 * and server-relative URLs (fetched with Basic auth when the server requires it).
165 * Falls back to nothing when the part has no loadable source.
168fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
169 val context = LocalContext.current
170 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
171 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
172 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
173 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
174 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
175 // FileChip to fall through to).
176 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
177 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
179 if (model === ImageResolving) return
181 ImageStatusBox { BrokenImageIcon() }
184 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
185 // the error state instead of being a permanent broken-image icon.
186 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
187 // Fullscreen zoomable viewer state: opened by tapping the inline image.
188 var showFullscreen by remember { mutableStateOf(false) }
189 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
190 ImageRequest.Builder(context)
193 // resolveModel already verified same-origin, so a String model can only point at
194 // the user's own server — attaching Basic auth over http (not just https) is safe.
195 // A data-URI model is a ByteArray, so gate on the model being a URL String.
196 if (model is String) ctx.basicAuthHeader?.let {
197 httpHeaders(NetworkHeaders.Builder().add("Authorization", it).build())
203 SubcomposeAsyncImage(
205 contentDescription = part.filename ?: stringResource(R.string.image),
206 contentScale = ContentScale.FillWidth,
208 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
209 .clip(MaterialTheme.shapes.small)
210 // Tap to open a fullscreen zoomable viewer so the user can inspect details
211 // without the inline 320dp height cap.
212 .clickable(role = Role.Button) { showFullscreen = true },
215 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
216 contentAlignment = Alignment.Center,
218 val loadingLabel = stringResource(R.string.loading)
219 CircularProgressIndicator(
220 modifier = Modifier.semantics { contentDescription = loadingLabel },
224 error = { ImageRetry(onRetry = { retryKey++ }) },
226 if (showFullscreen) {
227 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
228 showFullscreen = false
234 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
235 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
236 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
237 * the whole screen. Offers Save-to-gallery and Share actions via the top bar.
240internal fun FullscreenImageViewer(
241 request: ImageRequest,
242 contentDescription: String,
243 onDismiss: () -> Unit,
245 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
246 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
247 val scale = remember { Animatable(1f) }
248 var offsetX by remember { mutableFloatStateOf(0f) }
249 var offsetY by remember { mutableFloatStateOf(0f) }
250 val scope = rememberCoroutineScope()
251 val context = LocalContext.current
252 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
253 // the composable body (not inside the pointerInput coroutine) since it reads the
254 // composition's LocalReducedMotion.
255 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
256 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
257 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
258 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
259 // a continuous motion rather than a snap.
260 var dragOffsetY by remember { mutableFloatStateOf(0f) }
261 val dismissThreshold = NetworkConfig.imageViewerSwipeDismissThreshold
262 var showOverflow by remember { mutableStateOf(false) }
263 val saveLabel = stringResource(R.string.save_image)
264 val shareLabel = stringResource(R.string.share_image)
265 val savedLabel = stringResource(R.string.image_saved)
266 val saveFailedLabel = stringResource(R.string.image_save_failed)
268 onDismissRequest = onDismiss,
269 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
274 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
275 .pointerInput(Unit) {
276 detectTransformGestures { _, pan, zoom, _ ->
277 // Clamp the zoom so the image can't be scaled down below 1× (which would
278 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
279 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
280 scope.launch { scale.snapTo(newScale) }
281 if (newScale <= 1f) {
282 // Returning to 1× recenters so a prior pan doesn't leave the image
283 // stuck off-center at its rest size.
292 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
293 // vertical drag accumulates an offset; crossing the threshold dismisses.
294 .pointerInput(scale.value) {
295 if (scale.value <= 1.001f) {
296 detectVerticalDragGestures(
298 if (dragOffsetY.absoluteValue >= dismissThreshold) {
301 // Animate the snap-back to rest. A simple reset; the user's
302 // finger is already lifting so a full spring isn't needed.
306 onVerticalDrag = { _, dragAmount ->
307 // Only accumulate downward drags (positive Y) for dismiss, so an
308 // upward swipe doesn't accidentally trigger it.
309 if (dragAmount > 0) dragOffsetY += dragAmount
314 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
315 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
316 // defers the single-tap callback until it knows the touch isn't the first of a pair.
317 .pointerInput(Unit) {
321 if (scale.value > 1.001f) {
324 scale.animateTo(1f, zoomSpec)
326 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
330 onTap = { onDismiss() },
333 contentAlignment = Alignment.Center,
335 SubcomposeAsyncImage(
337 contentDescription = contentDescription,
338 contentScale = ContentScale.Fit,
342 scaleX = scale.value,
343 scaleY = scale.value,
344 translationX = offsetX,
345 // Apply the swipe-drag offset on top of any pan offset so the image
346 // follows the finger during a dismiss swipe.
347 translationY = offsetY + dragOffsetY,
350 // Close button in the top corner, inset below the status bar so it doesn't sit
351 // under the notch / status bar overlay on edge-to-edge devices.
352 androidx.compose.material3.IconButton(
355 .align(Alignment.TopEnd)
356 .windowInsetsPadding(WindowInsets.statusBars)
357 .padding(end = 8.dp),
361 contentDescription = stringResource(R.string.close),
362 tint = androidx.compose.ui.graphics.Color.White,
365 // Overflow with Save-to-gallery and Share. Save decodes the request's bytes via
366 // Coil and writes them to MediaStore (Pictures/opencode); Share fires ACTION_SEND
367 // with a content Uri. Both run off the main thread on the coroutine scope.
370 .align(Alignment.TopStart)
371 .windowInsetsPadding(WindowInsets.statusBars)
372 .padding(start = 8.dp),
374 androidx.compose.material3.IconButton(onClick = { showOverflow = true }) {
376 Icons.Filled.MoreVert,
377 contentDescription = stringResource(R.string.more_options),
378 tint = androidx.compose.ui.graphics.Color.White,
381 androidx.compose.material3.DropdownMenu(
382 expanded = showOverflow,
383 onDismissRequest = { showOverflow = false },
385 androidx.compose.material3.DropdownMenuItem(
386 text = { Text(saveLabel) },
390 val ok = runCatchingCancellable { saveImageToGallery(context, request, contentDescription) }.isSuccess
391 showToast(context, if (ok) savedLabel else saveFailedLabel)
395 androidx.compose.material3.DropdownMenuItem(
396 text = { Text(shareLabel) },
400 runCatchingCancellable { shareImage(context, request, contentDescription) }
401 .onFailure { showToast(context, saveFailedLabel) }
407 // Show the alt text / filename as a caption at the bottom of the viewer so a user
408 // can tell what the image is (e.g. an agent-named diagram) without leaving fullscreen.
409 // The contentDescription is already used by the image itself for TalkBack; the caption
410 // is hidden from a11y here to avoid double-announcing.
411 if (contentDescription.isNotBlank() && contentDescription != stringResource(R.string.image)) {
414 .align(Alignment.BottomCenter)
416 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.5f))
417 .windowInsetsPadding(WindowInsets.navigationBars)
418 .padding(horizontal = 16.dp, vertical = 8.dp),
422 color = androidx.compose.ui.graphics.Color.White,
423 style = MaterialTheme.typography.bodySmall,
425 overflow = androidx.compose.ui.text.style.TextOverflow.Ellipsis,
426 modifier = Modifier.semantics { invisibleToUser() },
430 // Zoom-level indicator: a small chip at the top-start showing the current zoom
431 // factor (e.g. "2.5×") whenever the image is zoomed beyond 1×, with a tap action
432 // to reset to 1×. Aids orientation when deep-zoomed and gives a one-tap reset
433 // (otherwise only a double-tap toggles between 1× and the zoom target).
434 if (scale.value > 1.01f) {
435 val zoomText = remember(scale.value) {
436 String.format(java.util.Locale.US, "%.1f×", scale.value)
438 val resetLabel = stringResource(R.string.reset_zoom)
439 androidx.compose.material3.Surface(
440 color = androidx.compose.ui.graphics.Color.Black.copy(alpha = 0.6f),
441 shape = MaterialTheme.shapes.small,
443 .align(Alignment.TopStart)
445 .windowInsetsPadding(WindowInsets.statusBars)
446 .clickable(role = Role.Button) {
447 scope.launch { scale.animateTo(1f, zoomSpec) }
451 .semantics { this.contentDescription = "$zoomText, $resetLabel" },
455 color = androidx.compose.ui.graphics.Color.White,
456 style = MaterialTheme.typography.labelSmall,
457 modifier = Modifier.padding(horizontal = 8.dp, vertical = 4.dp),
465/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
466 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
468private fun ImageRetry(onRetry: () -> Unit) {
469 val label = stringResource(R.string.tap_to_retry)
473 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
474 .clickable(role = Role.Button, onClick = onRetry),
475 contentAlignment = Alignment.Center,
477 Column(horizontalAlignment = Alignment.CenterHorizontally) {
481 style = MaterialTheme.typography.labelSmall,
482 color = MaterialTheme.colorScheme.onSurfaceVariant,
483 modifier = Modifier.padding(top = 4.dp),
489/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
490 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
491private val ImageResolving = Any()
493/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
494 * reserves the same space whether it's loading, failed, or unresolvable. */
496private fun ImageStatusBox(content: @Composable () -> Unit) {
498 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
499 contentAlignment = Alignment.Center,
504private fun BrokenImageIcon() {
506 Icons.Filled.BrokenImage,
507 contentDescription = stringResource(R.string.image_failed),
508 tint = MaterialTheme.colorScheme.onSurfaceVariant,
512/** Decode the image behind [request] via Coil and write it to the system MediaStore
513 * (Pictures/opencode) so it appears in the gallery. Returns true on success. Runs on a
514 * background dispatcher; callers should use runCatchingCancellable. */
515private suspend fun saveImageToGallery(
516 context: android.content.Context,
517 request: ImageRequest,
520 val bitmap = decodeRequestBitmap(context, request) ?: return false
521 val resolver = context.contentResolver
522 val name = (displayName.ifBlank { "opencode-image" }).take(100)
523 val timestamp = System.currentTimeMillis() / 1000
524 val values = android.content.ContentValues().apply {
525 put(android.provider.MediaStore.Images.Media.DISPLAY_NAME, "$name-${timestamp}.png")
526 put(android.provider.MediaStore.Images.Media.MIME_TYPE, "image/png")
527 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
528 put(android.provider.MediaStore.Images.Media.RELATIVE_PATH, "Pictures/opencode")
529 put(android.provider.MediaStore.Images.Media.IS_PENDING, 1)
532 val uri = resolver.insert(android.provider.MediaStore.Images.Media.EXTERNAL_CONTENT_URI, values)
535 resolver.openOutputStream(uri)?.use { out ->
536 bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, out)
537 } ?: error("no output stream")
538 if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.Q) {
540 values.put(android.provider.MediaStore.Images.Media.IS_PENDING, 0)
541 resolver.update(uri, values, null, null)
544 }.getOrDefault(false)
547/** Decode the image behind [request] to a Bitmap via Coil's ImageLoader. Returns null on
548 * failure. Suspending so callers run it on a background dispatcher. */
549private suspend fun decodeRequestBitmap(context: android.content.Context, request: ImageRequest): android.graphics.Bitmap? {
550 return soy.iko.opencode.util.runCatchingCancellableSuspend {
551 val loader = coil3.SingletonImageLoader.get(context)
552 val result = loader.execute(request)
553 result.image?.toBitmap()
557/** Share the image behind [request] via ACTION_SEND. Writes a temp file to the cache dir and
558 * grants the chooser read access via FileProvider. */
559private suspend fun shareImage(
560 context: android.content.Context,
561 request: ImageRequest,
564 val bitmap = decodeRequestBitmap(context, request) ?: error("decode failed")
565 val name = (displayName.ifBlank { "opencode-image" }).take(80)
566 val file = java.io.File(context.cacheDir, "share/$name-${System.currentTimeMillis()}.png")
567 file.parentFile?.mkdirs()
568 file.outputStream().use { bitmap.compress(android.graphics.Bitmap.CompressFormat.PNG, 100, it) }
569 val authority = "${context.packageName}.fileprovider"
570 val uri = androidx.core.content.FileProvider.getUriForFile(context, authority, file)
571 val intent = android.content.Intent(android.content.Intent.ACTION_SEND).apply {
573 putExtra(android.content.Intent.EXTRA_STREAM, uri)
574 addFlags(android.content.Intent.FLAG_GRANT_READ_URI_PERMISSION)
576 context.startActivity(android.content.Intent.createChooser(intent, displayName))
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list