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.remember
11import androidx.compose.ui.Alignment
12import androidx.compose.ui.Modifier
13import androidx.compose.ui.draw.clip
14import androidx.compose.ui.layout.ContentScale
15import androidx.compose.ui.platform.LocalContext
16import androidx.compose.ui.res.stringResource
17import androidx.compose.ui.semantics.contentDescription
18import androidx.compose.ui.semantics.semantics
19import androidx.compose.ui.unit.dp
20import coil.compose.SubcomposeAsyncImage
21import coil.request.ImageRequest
22import androidx.compose.material3.CircularProgressIndicator
23import androidx.compose.material3.Icon
24import androidx.compose.material.icons.Icons
25import androidx.compose.material.icons.filled.BrokenImage
26import soy.iko.opencode.R
27import soy.iko.opencode.data.model.FilePart
28import soy.iko.opencode.data.model.ServerProfile
29import soy.iko.opencode.data.network.HttpClientFactory
32 * Carries the bits needed to load an image off the opencode server: the base URL (for
33 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
36data class ImageLoadContext(
38 val basicAuthHeader: String?,
41/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
42fun ServerProfile.toImageContext(): ImageLoadContext {
43 val auth = if (hasAuth && !password.isNullOrEmpty()) {
44 val raw = "$username:$password".toByteArray()
45 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
49 return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
52/** True if the part references a raster/vector image we can render. */
53val FilePart.isImage: Boolean
54 get() = mime?.startsWith("image/") == true
57 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
58 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
60private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
62 if (!src.isNullOrBlank() && src.startsWith("data:")) {
63 val comma = src.indexOf(',')
64 if (comma < 0) return null
65 val payload = src.substring(comma + 1)
66 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
68 val url = url ?: return null
69 return if (url.startsWith("http://") || url.startsWith("https://")) {
72 // Resolve the URL (relative or server-absolute like "/media/x.png") against the
73 // base, collapsing any ../ segments.
74 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
75 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
76 val resolved = runCatching {
77 java.net.URI(base).resolve(url).normalize()
78 }.getOrElse { return null }
79 // Guard on same origin (scheme + host + port), not base-path prefix: the real
80 // risk is redirecting the request (with its Basic auth) to another host. A
81 // server-absolute path resolves to a different base *path* but the same origin,
82 // so it must still be allowed — a prefix check wrongly rejected those.
83 val sameOrigin = resolved.host != null &&
84 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
85 resolved.host.equals(baseUri.host, ignoreCase = true) &&
86 resolved.port == baseUri.port
87 if (sameOrigin) resolved.toString() else null
92 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
93 * and server-relative URLs (fetched with Basic auth when the server requires it).
94 * Falls back to nothing when the part has no loadable source.
97fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
98 val context = LocalContext.current
99 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
100 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
101 ImageRequest.Builder(context)
104 // Only send Basic auth over HTTPS; sending credentials over
105 // cleartext HTTP would expose them on the network.
106 val isHttps = (model as? String)?.startsWith("https://") == true
107 if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
112 SubcomposeAsyncImage(
114 contentDescription = part.filename ?: stringResource(R.string.image),
115 contentScale = ContentScale.FillWidth,
117 .heightIn(max = 320.dp)
118 .clip(MaterialTheme.shapes.small),
121 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
122 contentAlignment = Alignment.Center,
124 val loadingLabel = stringResource(R.string.loading)
125 CircularProgressIndicator(
126 modifier = Modifier.semantics { contentDescription = loadingLabel },
132 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
133 contentAlignment = Alignment.Center,
136 Icons.Filled.BrokenImage,
137 contentDescription = stringResource(R.string.image_failed),
138 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