git.iko.soy/opencode-android
on 0bc1eda help sign in
87 lines 3.3 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 HttpClientFactory.normalizeBaseUrl(ctx.baseUrl) + url.trimStart('/')
62 }
65/**
66 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
67 * and server-relative URLs (fetched with Basic auth when the server requires it).
68 * Falls back to nothing when the part has no loadable source.
69 */
70@Composable
71fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
72 val context = LocalContext.current
73 val model = remember(part.source, part.url, ctx.baseUrl) { part.resolveModel(ctx) } ?: return
74 val request = ImageRequest.Builder(context)
75 .data(model)
76 .apply { ctx.basicAuthHeader?.let { addHeader("Authorization", it) } }
77 .crossfade(true)
78 .build()
79 AsyncImage(
80 model = request,
81 contentDescription = part.filename ?: stringResource(R.string.image),
82 contentScale = ContentScale.FillWidth,
83 modifier = modifier
84 .heightIn(max = 320.dp)
85 .clip(RoundedCornerShape(12.dp)),
86 )