git.iko.soy/opencode-android
on 066cded help sign in
257 lines 12 kB history raw
1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.clickable
5import androidx.compose.foundation.layout.Box
6import androidx.compose.foundation.layout.Column
7import androidx.compose.foundation.layout.fillMaxWidth
8import androidx.compose.foundation.layout.heightIn
9import androidx.compose.foundation.layout.padding
10import androidx.compose.material3.MaterialTheme
11import androidx.compose.material3.Text
12import androidx.compose.runtime.Composable
13import androidx.compose.runtime.Immutable
14import androidx.compose.runtime.getValue
15import androidx.compose.runtime.mutableIntStateOf
16import androidx.compose.runtime.produceState
17import androidx.compose.runtime.remember
18import androidx.compose.runtime.setValue
19import androidx.compose.ui.Alignment
20import androidx.compose.ui.Modifier
21import androidx.compose.ui.draw.clip
22import androidx.compose.ui.layout.ContentScale
23import androidx.compose.ui.platform.LocalContext
24import androidx.compose.ui.res.stringResource
25import androidx.compose.ui.semantics.Role
26import androidx.compose.ui.semantics.contentDescription
27import androidx.compose.ui.semantics.semantics
28import androidx.compose.ui.unit.dp
29import coil.compose.SubcomposeAsyncImage
30import coil.request.ImageRequest
31import kotlinx.coroutines.Dispatchers
32import kotlinx.coroutines.withContext
33import androidx.compose.material3.CircularProgressIndicator
34import androidx.compose.material3.Icon
35import androidx.compose.material.icons.Icons
36import androidx.compose.material.icons.filled.BrokenImage
37import soy.iko.opencode.R
38import soy.iko.opencode.data.model.FilePart
39import soy.iko.opencode.data.model.ServerProfile
40import soy.iko.opencode.data.model.sourcePath
41import soy.iko.opencode.data.network.HttpClientFactory
43/**
44 * Carries the bits needed to load an image off the opencode server: the base URL (for
45 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
46 */
47@Immutable
48data class ImageLoadContext(
49 val baseUrl: String,
50 val basicAuthHeader: String?,
53/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
54fun ServerProfile.toImageContext(): ImageLoadContext {
55 val auth = if (hasAuth && !password.isNullOrEmpty()) {
56 val raw = "$username:$password".toByteArray()
57 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
58 } else {
59 null
60 }
61 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
62 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
63 // header attached — over cleartext http even when the stored URL is http://. This mirrors
64 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
65 return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
68/**
69 * The base URL used to resolve image URLs, after applying the same http->https upgrade the
70 * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
71 * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
72 * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
73 * a configured certificate pin forces HTTPS too, matching the REST channel.
74 */
75private fun ServerProfile.effectiveImageBaseUrl(): String {
76 val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
77 val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
78 return if (forceHttps && normalized.lowercase().startsWith("http://")) {
79 "https://" + normalized.substring("http://".length)
80 } else {
81 normalized
82 }
85/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
86private fun parseCertPins(raw: String?): List<String> =
87 raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
89/** True if the part references a raster/vector image we can render. */
90val FilePart.isImage: Boolean
91 get() = mime?.startsWith("image/") == true
93private fun decodeDataUri(data: String): ByteArray? {
94 val comma = data.indexOf(',')
95 if (comma < 0) return null
96 val header = data.substring(0, comma)
97 val payload = data.substring(comma + 1)
98 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
99 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
100 // which would throw and render the broken-image icon.
101 return runCatching {
102 if (header.contains(";base64", ignoreCase = true)) {
103 Base64.decode(payload, Base64.DEFAULT)
104 } else {
105 android.net.Uri.decode(payload).toByteArray()
106 }
107 }.getOrNull()
110/**
111 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
112 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
113 */
114private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
115 val src = sourcePath
116 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
117 val url = url ?: return null
118 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
119 if (url.startsWith("data:")) return decodeDataUri(url)
120 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
121 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
122 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
123 // otherwise get the server's Basic auth attached and leak the credentials to that host.
124 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
125 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
126 val resolved = runCatching {
127 java.net.URI(base).resolve(url).normalize()
128 }.getOrElse { return null }
129 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
130 // sending the request (with its Basic auth) to another host. A server-absolute path
131 // resolves to a different base *path* but the same origin, so it must still be allowed.
132 // Compare *effective* ports (substituting the scheme default when unspecified) so an
133 // absolute image URL that spells out the default port — "https://host:443/x.png" while
134 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
135 // when omitted, so a raw `==` would treat 443 and -1 as different).
136 val sameOrigin = resolved.host != null &&
137 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
138 resolved.host.equals(baseUri.host, ignoreCase = true) &&
139 effectivePort(resolved) == effectivePort(baseUri)
140 return if (sameOrigin) resolved.toString() else null
143/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
144private fun effectivePort(uri: java.net.URI): Int {
145 if (uri.port != -1) return uri.port
146 return when (uri.scheme?.lowercase()) {
147 "https" -> 443
148 "http" -> 80
149 else -> -1
150 }
153/**
154 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
155 * and server-relative URLs (fetched with Basic auth when the server requires it).
156 * Falls back to nothing when the part has no loadable source.
157 */
158@Composable
159fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
160 val context = LocalContext.current
161 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
162 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
163 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
164 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
165 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
166 // FileChip to fall through to).
167 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
168 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
169 }.value
170 if (model === ImageResolving) return
171 if (model == null) {
172 ImageStatusBox { BrokenImageIcon() }
173 return
174 }
175 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
176 // the error state instead of being a permanent broken-image icon.
177 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
178 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
179 ImageRequest.Builder(context)
180 .data(model)
181 .apply {
182 // resolveModel already verified same-origin, so a String model can only point at
183 // the user's own server — attaching Basic auth over http (not just https) is safe.
184 // A data-URI model is a ByteArray, so gate on the model being a URL String.
185 if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
186 }
187 .crossfade(true)
188 .build()
189 }
190 SubcomposeAsyncImage(
191 model = request,
192 contentDescription = part.filename ?: stringResource(R.string.image),
193 contentScale = ContentScale.FillWidth,
194 modifier = modifier
195 .heightIn(max = 320.dp)
196 .clip(MaterialTheme.shapes.small),
197 loading = {
198 Box(
199 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
200 contentAlignment = Alignment.Center,
201 ) {
202 val loadingLabel = stringResource(R.string.loading)
203 CircularProgressIndicator(
204 modifier = Modifier.semantics { contentDescription = loadingLabel },
205 )
206 }
207 },
208 error = { ImageRetry(onRetry = { retryKey++ }) },
209 )
212/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
213 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
214@Composable
215private fun ImageRetry(onRetry: () -> Unit) {
216 val label = stringResource(R.string.tap_to_retry)
217 Box(
218 modifier = Modifier
219 .fillMaxWidth()
220 .heightIn(min = 120.dp)
221 .clickable(role = Role.Button, onClick = onRetry),
222 contentAlignment = Alignment.Center,
223 ) {
224 Column(horizontalAlignment = Alignment.CenterHorizontally) {
225 BrokenImageIcon()
226 Text(
227 label,
228 style = MaterialTheme.typography.labelSmall,
229 color = MaterialTheme.colorScheme.onSurfaceVariant,
230 modifier = Modifier.padding(top = 4.dp),
231 )
232 }
233 }
236/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
237 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
238private val ImageResolving = Any()
240/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
241 * reserves the same space whether it's loading, failed, or unresolvable. */
242@Composable
243private fun ImageStatusBox(content: @Composable () -> Unit) {
244 Box(
245 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
246 contentAlignment = Alignment.Center,
247 ) { content() }
250@Composable
251private fun BrokenImageIcon() {
252 Icon(
253 Icons.Filled.BrokenImage,
254 contentDescription = stringResource(R.string.image_failed),
255 tint = MaterialTheme.colorScheme.onSurfaceVariant,
256 )