git.iko.soy/opencode-android
on 3e723a1 help sign in
99 lines 4.0 kB history raw
1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.layout.heightIn
5import androidx.compose.foundation.shape.RoundedCornerShape
6import androidx.compose.runtime.Composable
7import androidx.compose.runtime.remember
8import androidx.compose.ui.Modifier
9import androidx.compose.ui.draw.clip
10import androidx.compose.ui.layout.ContentScale
11import androidx.compose.ui.platform.LocalContext
12import androidx.compose.ui.res.stringResource
13import androidx.compose.ui.unit.dp
14import coil.compose.AsyncImage
15import coil.request.ImageRequest
16import soy.iko.opencode.R
17import soy.iko.opencode.data.model.FilePart
18import soy.iko.opencode.data.model.ServerProfile
19import soy.iko.opencode.data.network.HttpClientFactory
21/**
22 * Carries the bits needed to load an image off the opencode server: the base URL (for
23 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
24 */
25data class ImageLoadContext(
26 val baseUrl: String,
27 val basicAuthHeader: String?,
30/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
31fun ServerProfile.toImageContext(): ImageLoadContext {
32 val auth = if (hasAuth && !password.isNullOrEmpty()) {
33 val raw = "$username:$password".toByteArray()
34 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
35 } else {
36 null
37 }
38 return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
41/** True if the part references a raster/vector image we can render. */
42val FilePart.isImage: Boolean
43 get() = mime?.startsWith("image/") == true
45/**
46 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
47 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
48 */
49private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
50 val src = source
51 if (!src.isNullOrBlank() && src.startsWith("data:")) {
52 val comma = src.indexOf(',')
53 if (comma < 0) return null
54 val payload = src.substring(comma + 1)
55 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
56 }
57 val url = url ?: return null
58 return if (url.startsWith("http://") || url.startsWith("https://")) {
59 url
60 } else {
61 // Resolve relative URL against the base. Collapse any ../ segments to
62 // prevent path traversal from escaping the server's base path.
63 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
64 val resolved = java.net.URI(base).resolve(url).normalize().toString()
65 // Guard: if normalization escaped the base path, fall back to the base.
66 if (!resolved.startsWith(base)) base else resolved
67 }
70/**
71 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
72 * and server-relative URLs (fetched with Basic auth when the server requires it).
73 * Falls back to nothing when the part has no loadable source.
74 */
75@Composable
76fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
77 val context = LocalContext.current
78 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
79 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
80 ImageRequest.Builder(context)
81 .data(model)
82 .apply {
83 // Only send Basic auth over HTTPS; sending credentials over
84 // cleartext HTTP would expose them on the network.
85 val isHttps = (model as? String)?.startsWith("https://") == true
86 if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
87 }
88 .crossfade(true)
89 .build()
90 }
91 AsyncImage(
92 model = request,
93 contentDescription = part.filename ?: stringResource(R.string.image),
94 contentScale = ContentScale.FillWidth,
95 modifier = modifier
96 .heightIn(max = 320.dp)
97 .clip(RoundedCornerShape(12.dp)),
98 )