git.iko.soy/opencode-android
on d643c13 help sign in
130 lines 5.3 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.foundation.shape.RoundedCornerShape
8import androidx.compose.material3.MaterialTheme
9import androidx.compose.runtime.Composable
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.unit.dp
18import coil.compose.SubcomposeAsyncImage
19import coil.request.ImageRequest
20import androidx.compose.material3.CircularProgressIndicator
21import androidx.compose.material3.Icon
22import androidx.compose.material.icons.Icons
23import androidx.compose.material.icons.filled.BrokenImage
24import soy.iko.opencode.R
25import soy.iko.opencode.data.model.FilePart
26import soy.iko.opencode.data.model.ServerProfile
27import soy.iko.opencode.data.network.HttpClientFactory
29/**
30 * Carries the bits needed to load an image off the opencode server: the base URL (for
31 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
32 */
33data class ImageLoadContext(
34 val baseUrl: String,
35 val basicAuthHeader: String?,
38/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
39fun ServerProfile.toImageContext(): ImageLoadContext {
40 val auth = if (hasAuth && !password.isNullOrEmpty()) {
41 val raw = "$username:$password".toByteArray()
42 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
43 } else {
44 null
45 }
46 return ImageLoadContext(baseUrl = baseUrl, basicAuthHeader = auth)
49/** True if the part references a raster/vector image we can render. */
50val FilePart.isImage: Boolean
51 get() = mime?.startsWith("image/") == true
53/**
54 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
55 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
56 */
57private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
58 val src = source
59 if (!src.isNullOrBlank() && src.startsWith("data:")) {
60 val comma = src.indexOf(',')
61 if (comma < 0) return null
62 val payload = src.substring(comma + 1)
63 return runCatching { Base64.decode(payload, Base64.DEFAULT) }.getOrNull()
64 }
65 val url = url ?: return null
66 return if (url.startsWith("http://") || url.startsWith("https://")) {
67 url
68 } else {
69 // Resolve relative URL against the base. Collapse any ../ segments to
70 // prevent path traversal from escaping the server's base path.
71 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
72 val resolved = runCatching {
73 java.net.URI(base).resolve(url).normalize().toString()
74 }.getOrElse { return null }
75 // Guard: if normalization escaped the base path, return null (don't
76 // attempt to load the base URL itself as an image — it's not one).
77 if (!resolved.startsWith(base)) null else resolved
78 }
81/**
82 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
83 * and server-relative URLs (fetched with Basic auth when the server requires it).
84 * Falls back to nothing when the part has no loadable source.
85 */
86@Composable
87fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
88 val context = LocalContext.current
89 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
90 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader) {
91 ImageRequest.Builder(context)
92 .data(model)
93 .apply {
94 // Only send Basic auth over HTTPS; sending credentials over
95 // cleartext HTTP would expose them on the network.
96 val isHttps = (model as? String)?.startsWith("https://") == true
97 if (isHttps) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
98 }
99 .crossfade(true)
100 .build()
101 }
102 SubcomposeAsyncImage(
103 model = request,
104 contentDescription = part.filename ?: stringResource(R.string.image),
105 contentScale = ContentScale.FillWidth,
106 modifier = modifier
107 .heightIn(max = 320.dp)
108 .clip(RoundedCornerShape(12.dp)),
109 loading = {
110 Box(
111 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
112 contentAlignment = Alignment.Center,
113 ) {
114 CircularProgressIndicator()
115 }
116 },
117 error = {
118 Box(
119 modifier = Modifier.fillMaxWidth().heightIn(min = 120.dp),
120 contentAlignment = Alignment.Center,
121 ) {
122 Icon(
123 Icons.Filled.BrokenImage,
124 contentDescription = stringResource(R.string.image_failed),
125 tint = MaterialTheme.colorScheme.onSurfaceVariant,
126 )
127 }
128 },
129 )