1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.fillMaxWidth
6import androidx.compose.foundation.layout.heightIn
7import androidx.compose.material3.MaterialTheme
8import androidx.compose.runtime.Composable
9import androidx.compose.runtime.Immutable
10import androidx.compose.runtime.produceState
11import androidx.compose.runtime.remember
12import androidx.compose.ui.Alignment
13import androidx.compose.ui.Modifier
14import androidx.compose.ui.draw.clip
15import androidx.compose.ui.layout.ContentScale
16import androidx.compose.ui.platform.LocalContext
17import androidx.compose.ui.res.stringResource
18import androidx.compose.ui.semantics.contentDescription
19import androidx.compose.ui.semantics.semantics
20import androidx.compose.ui.unit.dp
21import coil.compose.SubcomposeAsyncImage
22import coil.request.ImageRequest
23import kotlinx.coroutines.Dispatchers
24import kotlinx.coroutines.withContext
25import androidx.compose.material3.CircularProgressIndicator
26import androidx.compose.material3.Icon
27import androidx.compose.material.icons.Icons
28import androidx.compose.material.icons.filled.BrokenImage
29import soy.iko.opencode.R
30import soy.iko.opencode.data.model.FilePart
31import soy.iko.opencode.data.model.ServerProfile
32import soy.iko.opencode.data.model.sourcePath
33import soy.iko.opencode.data.network.HttpClientFactory
36 * Carries the bits needed to load an image off the opencode server: the base URL (for
37 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
40data class ImageLoadContext(
42 val basicAuthHeader: String?,
45/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
46fun ServerProfile.toImageContext(): ImageLoadContext {
47 val auth = if (hasAuth && !password.isNullOrEmpty()) {
48 val raw = "$username:$password".toByteArray()
49 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
53 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
54 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
55 // header attached — over cleartext http even when the stored URL is http://. This mirrors
56 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
57 return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
61 * The base URL used to resolve image URLs, after applying the same http->https upgrade the
62 * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
63 * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
64 * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
65 * a configured certificate pin forces HTTPS too, matching the REST channel.
67private fun ServerProfile.effectiveImageBaseUrl(): String {
68 val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
69 val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
70 return if (forceHttps && normalized.lowercase().startsWith("http://")) {
71 "https://" + normalized.substring("http://".length)
77/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
78private fun parseCertPins(raw: String?): List<String> =
79 raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
81/** True if the part references a raster/vector image we can render. */
82val FilePart.isImage: Boolean
83 get() = mime?.startsWith("image/") == true
85private fun decodeDataUri(data: String): ByteArray? {
86 val comma = data.indexOf(',')
87 if (comma < 0) return null
88 val header = data.substring(0, comma)
89 val payload = data.substring(comma + 1)
90 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
91 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
92 // which would throw and render the broken-image icon.
94 if (header.contains(";base64", ignoreCase = true)) {
95 Base64.decode(payload, Base64.DEFAULT)
97 android.net.Uri.decode(payload).toByteArray()
103 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
104 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
106private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
108 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
109 val url = url ?: return null
110 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
111 if (url.startsWith("data:")) return decodeDataUri(url)
112 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
113 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
114 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
115 // otherwise get the server's Basic auth attached and leak the credentials to that host.
116 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
117 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
118 val resolved = runCatching {
119 java.net.URI(base).resolve(url).normalize()
120 }.getOrElse { return null }
121 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
122 // sending the request (with its Basic auth) to another host. A server-absolute path
123 // resolves to a different base *path* but the same origin, so it must still be allowed.
124 // Compare *effective* ports (substituting the scheme default when unspecified) so an
125 // absolute image URL that spells out the default port — "https://host:443/x.png" while
126 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
127 // when omitted, so a raw `==` would treat 443 and -1 as different).
128 val sameOrigin = resolved.host != null &&
129 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
130 resolved.host.equals(baseUri.host, ignoreCase = true) &&
131 effectivePort(resolved) == effectivePort(baseUri)
132 return if (sameOrigin) resolved.toString() else null
135/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
136private fun effectivePort(uri: java.net.URI): Int {
137 if (uri.port != -1) return uri.port
138 return when (uri.scheme?.lowercase()) {
146 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
147 * and server-relative URLs (fetched with Basic auth when the server requires it).
148 * Falls back to nothing when the part has no loadable source.
151fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
152 val context = LocalContext.current
153 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
154 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
155 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
156 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
157 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
158 // FileChip to fall through to).
159 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
160 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
162 if (model === ImageResolving) return
164 ImageStatusBox { BrokenImageIcon() }
167 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model) {
168 ImageRequest.Builder(context)
171 // resolveModel already verified same-origin, so a String model can only point at
172 // the user's own server — attaching Basic auth over http (not just https) is safe.
173 // A data-URI model is a ByteArray, so gate on the model being a URL String.
174 if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
179 SubcomposeAsyncImage(
181 contentDescription = part.filename ?: stringResource(R.string.image),
182 contentScale = ContentScale.FillWidth,
184 .heightIn(max = 320.dp)
185 .clip(MaterialTheme.shapes.small),
188 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
189 contentAlignment = Alignment.Center,
191 val loadingLabel = stringResource(R.string.loading)
192 CircularProgressIndicator(
193 modifier = Modifier.semantics { contentDescription = loadingLabel },
197 error = { ImageStatusBox { BrokenImageIcon() } },
201/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
202 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
203private val ImageResolving = Any()
205/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
206 * reserves the same space whether it's loading, failed, or unresolvable. */
208private fun ImageStatusBox(content: @Composable () -> Unit) {
210 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
211 contentAlignment = Alignment.Center,
216private fun BrokenImageIcon() {
218 Icons.Filled.BrokenImage,
219 contentDescription = stringResource(R.string.image_failed),
220 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