git.iko.soy/opencode-android
on bc6b45e help sign in
143 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 return if (url.startsWith("http://") || url.startsWith("https://")) {
70 url
71 } else {
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
88 }
91/**
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.
95 */
96@Composable
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)
102 .data(model)
103 .apply {
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) }
108 }
109 .crossfade(true)
110 .build()
111 }
112 SubcomposeAsyncImage(
113 model = request,
114 contentDescription = part.filename ?: stringResource(R.string.image),
115 contentScale = ContentScale.FillWidth,
116 modifier = modifier
117 .heightIn(max = 320.dp)
118 .clip(MaterialTheme.shapes.small),
119 loading = {
120 Box(
121 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
122 contentAlignment = Alignment.Center,
123 ) {
124 val loadingLabel = stringResource(R.string.loading)
125 CircularProgressIndicator(
126 modifier = Modifier.semantics { contentDescription = loadingLabel },
127 )
128 }
129 },
130 error = {
131 Box(
132 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
133 contentAlignment = Alignment.Center,
134 ) {
135 Icon(
136 Icons.Filled.BrokenImage,
137 contentDescription = stringResource(R.string.image_failed),
138 tint = MaterialTheme.colorScheme.onSurfaceVariant,
139 )
140 }
141 },
142 )