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.layout.Box
9import androidx.compose.foundation.layout.Column
10import androidx.compose.foundation.layout.fillMaxSize
11import androidx.compose.foundation.layout.fillMaxWidth
12import androidx.compose.foundation.layout.heightIn
13import androidx.compose.foundation.layout.padding
14import androidx.compose.material3.CircularProgressIndicator
15import androidx.compose.material3.Icon
16import androidx.compose.material3.MaterialTheme
17import androidx.compose.material3.Text
18import androidx.compose.runtime.Composable
19import androidx.compose.runtime.Immutable
20import androidx.compose.runtime.getValue
21import androidx.compose.runtime.mutableFloatStateOf
22import androidx.compose.runtime.mutableIntStateOf
23import androidx.compose.runtime.mutableStateOf
24import androidx.compose.runtime.produceState
25import androidx.compose.runtime.remember
26import androidx.compose.runtime.rememberCoroutineScope
27import androidx.compose.runtime.setValue
28import androidx.compose.ui.Alignment
29import androidx.compose.ui.Modifier
30import androidx.compose.ui.draw.clip
31import androidx.compose.ui.graphics.graphicsLayer
32import androidx.compose.ui.input.pointer.pointerInput
33import androidx.compose.ui.layout.ContentScale
34import androidx.compose.ui.platform.LocalContext
35import androidx.compose.ui.res.stringResource
36import androidx.compose.ui.semantics.Role
37import androidx.compose.ui.semantics.contentDescription
38import androidx.compose.ui.semantics.semantics
39import androidx.compose.ui.unit.dp
40import androidx.compose.ui.window.Dialog
41import androidx.compose.ui.window.DialogProperties
42import coil.compose.SubcomposeAsyncImage
43import coil.request.ImageRequest
44import kotlinx.coroutines.Dispatchers
45import kotlinx.coroutines.launch
46import androidx.compose.animation.core.Animatable
47import kotlinx.coroutines.withContext
48import androidx.compose.material.icons.Icons
49import androidx.compose.material.icons.filled.BrokenImage
50import androidx.compose.material.icons.filled.Close
51import soy.iko.opencode.R
52import soy.iko.opencode.data.model.FilePart
53import soy.iko.opencode.data.model.ServerProfile
54import soy.iko.opencode.data.model.sourcePath
55import soy.iko.opencode.data.network.HttpClientFactory
58 * Carries the bits needed to load an image off the opencode server: the base URL (for
59 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
62data class ImageLoadContext(
64 val basicAuthHeader: String?,
67/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
68fun ServerProfile.toImageContext(): ImageLoadContext {
69 val auth = if (hasAuth && !password.isNullOrEmpty()) {
70 val raw = "$username:$password".toByteArray()
71 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
75 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
76 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
77 // header attached — over cleartext http even when the stored URL is http://. This mirrors
78 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
79 return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
83 * The base URL used to resolve image URLs, after applying the same http->https upgrade the
84 * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
85 * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
86 * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
87 * a configured certificate pin forces HTTPS too, matching the REST channel.
89private fun ServerProfile.effectiveImageBaseUrl(): String {
90 val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
91 val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
92 return if (forceHttps && normalized.lowercase().startsWith("http://")) {
93 "https://" + normalized.substring("http://".length)
99/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
100private fun parseCertPins(raw: String?): List<String> =
101 raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
103/** True if the part references a raster/vector image we can render. */
104val FilePart.isImage: Boolean
105 get() = mime?.startsWith("image/") == true
107private fun decodeDataUri(data: String): ByteArray? {
108 val comma = data.indexOf(',')
109 if (comma < 0) return null
110 val header = data.substring(0, comma)
111 val payload = data.substring(comma + 1)
112 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
113 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
114 // which would throw and render the broken-image icon.
116 if (header.contains(";base64", ignoreCase = true)) {
117 Base64.decode(payload, Base64.DEFAULT)
119 android.net.Uri.decode(payload).toByteArray()
125 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
126 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
128private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
130 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
131 val url = url ?: return null
132 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
133 if (url.startsWith("data:")) return decodeDataUri(url)
134 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
135 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
136 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
137 // otherwise get the server's Basic auth attached and leak the credentials to that host.
138 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
139 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
140 val resolved = runCatching {
141 java.net.URI(base).resolve(url).normalize()
142 }.getOrElse { return null }
143 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
144 // sending the request (with its Basic auth) to another host. A server-absolute path
145 // resolves to a different base *path* but the same origin, so it must still be allowed.
146 // Compare *effective* ports (substituting the scheme default when unspecified) so an
147 // absolute image URL that spells out the default port — "https://host:443/x.png" while
148 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
149 // when omitted, so a raw `==` would treat 443 and -1 as different).
150 val sameOrigin = resolved.host != null &&
151 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
152 resolved.host.equals(baseUri.host, ignoreCase = true) &&
153 effectivePort(resolved) == effectivePort(baseUri)
154 return if (sameOrigin) resolved.toString() else null
157/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
158private fun effectivePort(uri: java.net.URI): Int {
159 if (uri.port != -1) return uri.port
160 return when (uri.scheme?.lowercase()) {
168 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
169 * and server-relative URLs (fetched with Basic auth when the server requires it).
170 * Falls back to nothing when the part has no loadable source.
173fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
174 val context = LocalContext.current
175 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
176 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
177 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
178 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
179 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
180 // FileChip to fall through to).
181 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
182 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
184 if (model === ImageResolving) return
186 ImageStatusBox { BrokenImageIcon() }
189 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
190 // the error state instead of being a permanent broken-image icon.
191 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
192 // Fullscreen zoomable viewer state: opened by tapping the inline image.
193 var showFullscreen by remember { mutableStateOf(false) }
194 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
195 ImageRequest.Builder(context)
198 // resolveModel already verified same-origin, so a String model can only point at
199 // the user's own server — attaching Basic auth over http (not just https) is safe.
200 // A data-URI model is a ByteArray, so gate on the model being a URL String.
201 if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
206 SubcomposeAsyncImage(
208 contentDescription = part.filename ?: stringResource(R.string.image),
209 contentScale = ContentScale.FillWidth,
211 .heightIn(max = 320.dp)
212 .clip(MaterialTheme.shapes.small)
213 // Tap to open a fullscreen zoomable viewer so the user can inspect details
214 // without the inline 320dp height cap.
215 .clickable(role = Role.Button) { showFullscreen = true },
218 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
219 contentAlignment = Alignment.Center,
221 val loadingLabel = stringResource(R.string.loading)
222 CircularProgressIndicator(
223 modifier = Modifier.semantics { contentDescription = loadingLabel },
227 error = { ImageRetry(onRetry = { retryKey++ }) },
229 if (showFullscreen) {
230 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
231 showFullscreen = false
237 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
238 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
239 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
243private fun FullscreenImageViewer(
244 request: ImageRequest,
245 contentDescription: String,
246 onDismiss: () -> Unit,
248 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
249 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
250 val scale = remember { Animatable(1f) }
251 var offsetX by remember { mutableFloatStateOf(0f) }
252 var offsetY by remember { mutableFloatStateOf(0f) }
253 val scope = rememberCoroutineScope()
254 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
255 // the composable body (not inside the pointerInput coroutine) since it reads the
256 // composition's LocalReducedMotion.
257 val zoomSpec = rememberMotionTween<Float>(IMAGE_ZOOM_ANIM_MS)
259 onDismissRequest = onDismiss,
260 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
265 .background(androidx.compose.ui.graphics.Color.Black)
266 .pointerInput(Unit) {
267 detectTransformGestures { _, pan, zoom, _ ->
268 // Clamp the zoom so the image can't be scaled down below 1× (which would
269 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
270 val newScale = (scale.value * zoom).coerceIn(1f, MAX_IMAGE_ZOOM)
271 scope.launch { scale.snapTo(newScale) }
272 if (newScale <= 1f) {
273 // Returning to 1× recenters so a prior pan doesn't leave the image
274 // stuck off-center at its rest size.
283 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
284 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
285 // defers the single-tap callback until it knows the touch isn't the first of a pair.
286 .pointerInput(Unit) {
290 if (scale.value > 1.001f) {
293 scale.animateTo(1f, zoomSpec)
295 scale.animateTo(DOUBLE_TAP_ZOOM, zoomSpec)
299 onTap = { onDismiss() },
302 contentAlignment = Alignment.Center,
304 SubcomposeAsyncImage(
306 contentDescription = contentDescription,
307 contentScale = ContentScale.Fit,
311 scaleX = scale.value,
312 scaleY = scale.value,
313 translationX = offsetX,
314 translationY = offsetY,
317 // Close button in the top corner.
318 androidx.compose.material3.IconButton(
320 modifier = Modifier.align(Alignment.TopEnd).padding(16.dp),
324 contentDescription = stringResource(R.string.close),
325 tint = androidx.compose.ui.graphics.Color.White,
332/** Maximum zoom factor applied by pinch or double-tap in the fullscreen image viewer. */
333private const val MAX_IMAGE_ZOOM = 5f
334/** Zoom factor a double-tap jumps to when starting from 1×. */
335private const val DOUBLE_TAP_ZOOM = 2.5f
336/** Duration (ms) of the animated double-tap zoom transition. */
337private const val IMAGE_ZOOM_ANIM_MS = 220
339/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
340 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
342private fun ImageRetry(onRetry: () -> Unit) {
343 val label = stringResource(R.string.tap_to_retry)
347 .heightIn(min = 120.dp)
348 .clickable(role = Role.Button, onClick = onRetry),
349 contentAlignment = Alignment.Center,
351 Column(horizontalAlignment = Alignment.CenterHorizontally) {
355 style = MaterialTheme.typography.labelSmall,
356 color = MaterialTheme.colorScheme.onSurfaceVariant,
357 modifier = Modifier.padding(top = 4.dp),
363/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
364 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
365private val ImageResolving = Any()
367/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
368 * reserves the same space whether it's loading, failed, or unresolvable. */
370private fun ImageStatusBox(content: @Composable () -> Unit) {
372 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
373 contentAlignment = Alignment.Center,
378private fun BrokenImageIcon() {
380 Icons.Filled.BrokenImage,
381 contentDescription = stringResource(R.string.image_failed),
382 tint = MaterialTheme.colorScheme.onSurfaceVariant,
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