git.iko.soy/opencode-android
on 8bc2cce help sign in
140 lines 6.0 kB history raw
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
31/**
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.
34 */
35@Immutable
36data class ImageLoadContext(
37 val baseUrl: String,
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)
46 } else {
47 null
48 }
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
56/**
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.
59 */
60private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
61 val src = source
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()
67 }
68 val url = url ?: return null
69 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
70 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
71 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
72 // otherwise get the server's Basic auth attached and leak the credentials to that host.
73 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
74 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
75 val resolved = runCatching {
76 java.net.URI(base).resolve(url).normalize()
77 }.getOrElse { return null }
78 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
79 // sending the request (with its Basic auth) to another host. A server-absolute path
80 // resolves to a different base *path* but the same origin, so it must still be allowed.
81 val sameOrigin = resolved.host != null &&
82 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
83 resolved.host.equals(baseUri.host, ignoreCase = true) &&
84 resolved.port == baseUri.port
85 return if (sameOrigin) resolved.toString() else null
88/**
89 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
90 * and server-relative URLs (fetched with Basic auth when the server requires it).
91 * Falls back to nothing when the part has no loadable source.
92 */
93@Composable
94fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
95 val context = LocalContext.current
96 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
97 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
98 ImageRequest.Builder(context)
99 .data(model)
100 .apply {
101 // Only send Basic auth over HTTPS; sending credentials over
102 // cleartext HTTP would expose them on the network.
103 val isHttps = (model as? String)?.startsWith("https://") == true
104 if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
105 }
106 .crossfade(true)
107 .build()
108 }
109 SubcomposeAsyncImage(
110 model = request,
111 contentDescription = part.filename ?: stringResource(R.string.image),
112 contentScale = ContentScale.FillWidth,
113 modifier = modifier
114 .heightIn(max = 320.dp)
115 .clip(MaterialTheme.shapes.small),
116 loading = {
117 Box(
118 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
119 contentAlignment = Alignment.Center,
120 ) {
121 val loadingLabel = stringResource(R.string.loading)
122 CircularProgressIndicator(
123 modifier = Modifier.semantics { contentDescription = loadingLabel },
124 )
125 }
126 },
127 error = {
128 Box(
129 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
130 contentAlignment = Alignment.Center,
131 ) {
132 Icon(
133 Icons.Filled.BrokenImage,
134 contentDescription = stringResource(R.string.image_failed),
135 tint = MaterialTheme.colorScheme.onSurfaceVariant,
136 )
137 }
138 },
139 )