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