git.iko.soy/opencode-android
on 366f92e help sign in
417 lines 20 kB history raw
1package soy.iko.opencode.ui.components
3import android.util.Base64
4import androidx.compose.foundation.background
5import androidx.compose.foundation.clickable
6import androidx.compose.foundation.gestures.detectTapGestures
7import androidx.compose.foundation.gestures.detectTransformGestures
8import androidx.compose.foundation.gestures.detectVerticalDragGestures
9import androidx.compose.foundation.layout.Box
10import androidx.compose.foundation.layout.Column
11import androidx.compose.foundation.layout.WindowInsets
12import androidx.compose.foundation.layout.fillMaxSize
13import androidx.compose.foundation.layout.fillMaxWidth
14import androidx.compose.foundation.layout.heightIn
15import androidx.compose.foundation.layout.padding
16import androidx.compose.foundation.layout.statusBars
17import androidx.compose.foundation.layout.windowInsetsPadding
18import androidx.compose.material3.CircularProgressIndicator
19import androidx.compose.material3.Icon
20import androidx.compose.material3.MaterialTheme
21import androidx.compose.material3.Text
22import androidx.compose.runtime.Composable
23import androidx.compose.runtime.Immutable
24import androidx.compose.runtime.getValue
25import androidx.compose.runtime.mutableFloatStateOf
26import androidx.compose.runtime.mutableIntStateOf
27import androidx.compose.runtime.mutableStateOf
28import androidx.compose.runtime.produceState
29import androidx.compose.runtime.remember
30import androidx.compose.runtime.rememberCoroutineScope
31import androidx.compose.runtime.setValue
32import androidx.compose.ui.Alignment
33import androidx.compose.ui.Modifier
34import androidx.compose.ui.draw.clip
35import androidx.compose.ui.graphics.graphicsLayer
36import androidx.compose.ui.input.pointer.pointerInput
37import androidx.compose.ui.layout.ContentScale
38import androidx.compose.ui.platform.LocalContext
39import androidx.compose.ui.res.stringResource
40import androidx.compose.ui.semantics.Role
41import androidx.compose.ui.semantics.contentDescription
42import androidx.compose.ui.semantics.semantics
43import androidx.compose.ui.unit.dp
44import androidx.compose.ui.window.Dialog
45import androidx.compose.ui.window.DialogProperties
46import coil.compose.SubcomposeAsyncImage
47import coil.request.ImageRequest
48import kotlinx.coroutines.Dispatchers
49import kotlinx.coroutines.launch
50import kotlin.math.absoluteValue
51import androidx.compose.animation.core.Animatable
52import kotlinx.coroutines.withContext
53import androidx.compose.material.icons.Icons
54import androidx.compose.material.icons.filled.BrokenImage
55import androidx.compose.material.icons.filled.Close
56import soy.iko.opencode.R
57import soy.iko.opencode.data.model.FilePart
58import soy.iko.opencode.data.model.ServerProfile
59import soy.iko.opencode.data.model.sourcePath
60import soy.iko.opencode.data.network.HttpClientFactory
61import soy.iko.opencode.data.network.NetworkConfig
63/**
64 * Carries the bits needed to load an image off the opencode server: the base URL (for
65 * resolving relative `url`s) and an optional HTTP Basic header for protected servers.
66 */
67@Immutable
68data class ImageLoadContext(
69 val baseUrl: String,
70 val basicAuthHeader: String?,
73/** Build an [ImageLoadContext] from a resolved connection profile (password included). */
74fun ServerProfile.toImageContext(): ImageLoadContext {
75 val auth = if (hasAuth && !password.isNullOrEmpty()) {
76 val raw = "$username:$password".toByteArray()
77 "Basic " + Base64.encodeToString(raw, Base64.NO_WRAP)
78 } else {
79 null
80 }
81 // Resolve image URLs against the HTTPS-upgraded base URL, not the raw stored one, so a
82 // requireHttps (or certificate-pinned) profile never fetches an image — with the Basic auth
83 // header attached — over cleartext http even when the stored URL is http://. This mirrors
84 // how HttpClientFactory computes the effective base URL for the REST/SSE channel.
85 return ImageLoadContext(baseUrl = effectiveImageBaseUrl(), basicAuthHeader = auth)
88/**
89 * The base URL used to resolve image URLs, after applying the same http->https upgrade the
90 * REST/SSE client uses (see HttpClientFactory.effectiveBaseUrl, the source of truth — that
91 * helper and its pin parser are private there and this file may not edit HttpClientFactory, so
92 * the logic is replicated here and must be kept in sync). A pin is meaningless over cleartext, so
93 * a configured certificate pin forces HTTPS too, matching the REST channel.
94 */
95private fun ServerProfile.effectiveImageBaseUrl(): String {
96 val normalized = HttpClientFactory.normalizeBaseUrl(baseUrl)
97 val forceHttps = requireHttps || parseCertPins(certPin).isNotEmpty()
98 return if (forceHttps && normalized.lowercase().startsWith("http://")) {
99 "https://" + normalized.substring("http://".length)
100 } else {
101 normalized
102 }
105/** Split a certificate-pin field into individual pins; mirrors HttpClientFactory.parsePins. */
106private fun parseCertPins(raw: String?): List<String> =
107 raw?.split(Regex("[\\s,]+"))?.map { it.trim() }?.filter { it.isNotEmpty() } ?: emptyList()
109/** True if the part references a raster/vector image we can render. */
110val FilePart.isImage: Boolean
111 get() = mime?.startsWith("image/") == true
113private fun decodeDataUri(data: String): ByteArray? {
114 val comma = data.indexOf(',')
115 if (comma < 0) return null
116 val header = data.substring(0, comma)
117 val payload = data.substring(comma + 1)
118 // Only ";base64" payloads are Base64; a plain/percent-encoded data URI (e.g.
119 // "data:image/svg+xml,%3Csvg…") must be percent-decoded to bytes instead of Base64-decoded,
120 // which would throw and render the broken-image icon.
121 return runCatching {
122 if (header.contains(";base64", ignoreCase = true)) {
123 Base64.decode(payload, Base64.DEFAULT)
124 } else {
125 android.net.Uri.decode(payload).toByteArray()
126 }
127 }.getOrNull()
130/**
131 * Resolve a [FilePart] to a Coil-loadable model: a decoded [ByteArray] for inline
132 * data URIs, an absolute URL string otherwise. Returns null when nothing loadable.
133 */
134private fun FilePart.resolveModel(ctx: ImageLoadContext): Any? {
135 val src = sourcePath
136 if (!src.isNullOrBlank() && src.startsWith("data:")) return decodeDataUri(src)
137 val url = url ?: return null
138 // Attachments are persisted as a data URL in `part.url`, so a data URI can arrive here too.
139 if (url.startsWith("data:")) return decodeDataUri(url)
140 // Resolve the URL (absolute, relative, or server-absolute like "/media/x.png") against
141 // the base, collapsing any ../ segments. Both absolute and relative forms must clear the
142 // same-origin check below — an absolute foreign URL (e.g. "https://evil.com/x.png") would
143 // otherwise get the server's Basic auth attached and leak the credentials to that host.
144 val base = HttpClientFactory.normalizeBaseUrl(ctx.baseUrl)
145 val baseUri = runCatching { java.net.URI(base) }.getOrElse { return null }
146 val resolved = runCatching {
147 java.net.URI(base).resolve(url).normalize()
148 }.getOrElse { return null }
149 // Guard on same origin (scheme + host + port), not base-path prefix: the real risk is
150 // sending the request (with its Basic auth) to another host. A server-absolute path
151 // resolves to a different base *path* but the same origin, so it must still be allowed.
152 // Compare *effective* ports (substituting the scheme default when unspecified) so an
153 // absolute image URL that spells out the default port — "https://host:443/x.png" while
154 // the base is "https://host/" — isn't wrongly rejected as cross-origin (URI.port is -1
155 // when omitted, so a raw `==` would treat 443 and -1 as different).
156 val sameOrigin = resolved.host != null &&
157 resolved.scheme.equals(baseUri.scheme, ignoreCase = true) &&
158 resolved.host.equals(baseUri.host, ignoreCase = true) &&
159 effectivePort(resolved) == effectivePort(baseUri)
160 return if (sameOrigin) resolved.toString() else null
163/** The URI's port, or the scheme's default (80/443) when unspecified (`port == -1`). */
164private fun effectivePort(uri: java.net.URI): Int {
165 if (uri.port != -1) return uri.port
166 return when (uri.scheme?.lowercase()) {
167 "https" -> 443
168 "http" -> 80
169 else -> -1
170 }
173/**
174 * Renders an image attachment from a [FilePart]. Handles inline data URIs (no network)
175 * and server-relative URLs (fetched with Basic auth when the server requires it).
176 * Falls back to nothing when the part has no loadable source.
177 */
178@Composable
179fun RemoteImage(part: FilePart, ctx: ImageLoadContext, modifier: Modifier = Modifier) {
180 val context = LocalContext.current
181 // Distinguish "still resolving off-thread" from "resolved to nothing loadable": produceState
182 // seeds a sentinel so the first (pre-resolve) frame renders nothing (as before), but once
183 // resolveModel returns null — a cross-origin URL (blocked so the server's Basic auth can't
184 // leak off-origin), a malformed data URI, or a source with no path — we show the broken-image
185 // fallback instead of an empty gap (PartView already committed to RemoteImage, so there's no
186 // FileChip to fall through to).
187 val model = produceState<Any?>(initialValue = ImageResolving, part.source, part.url, ctx.baseUrl) {
188 value = withContext(Dispatchers.Default) { part.resolveModel(ctx) }
189 }.value
190 if (model === ImageResolving) return
191 if (model == null) {
192 ImageStatusBox { BrokenImageIcon() }
193 return
194 }
195 // Bumping retryKey re-builds the ImageRequest, so a failed load can be re-issued by tapping
196 // the error state instead of being a permanent broken-image icon.
197 var retryKey by remember(part.source, part.url, ctx.baseUrl) { mutableIntStateOf(0) }
198 // Fullscreen zoomable viewer state: opened by tapping the inline image.
199 var showFullscreen by remember { mutableStateOf(false) }
200 val request = remember(part.source, part.url, ctx.baseUrl, ctx.basicAuthHeader, model, retryKey) {
201 ImageRequest.Builder(context)
202 .data(model)
203 .apply {
204 // resolveModel already verified same-origin, so a String model can only point at
205 // the user's own server — attaching Basic auth over http (not just https) is safe.
206 // A data-URI model is a ByteArray, so gate on the model being a URL String.
207 if (model is String) ctx.basicAuthHeader?.let { addHeader("Authorization", it) }
208 }
209 .crossfade(true)
210 .build()
211 }
212 SubcomposeAsyncImage(
213 model = request,
214 contentDescription = part.filename ?: stringResource(R.string.image),
215 contentScale = ContentScale.FillWidth,
216 modifier = modifier
217 .heightIn(max = NetworkConfig.inlineImageMaxHeightDp.dp)
218 .clip(MaterialTheme.shapes.small)
219 // Tap to open a fullscreen zoomable viewer so the user can inspect details
220 // without the inline 320dp height cap.
221 .clickable(role = Role.Button) { showFullscreen = true },
222 loading = {
223 Box(
224 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
225 contentAlignment = Alignment.Center,
226 ) {
227 val loadingLabel = stringResource(R.string.loading)
228 CircularProgressIndicator(
229 modifier = Modifier.semantics { contentDescription = loadingLabel },
230 )
231 }
232 },
233 error = { ImageRetry(onRetry = { retryKey++ }) },
234 )
235 if (showFullscreen) {
236 FullscreenImageViewer(request = request, contentDescription = part.filename ?: stringResource(R.string.image)) {
237 showFullscreen = false
238 }
239 }
242/**
243 * Fullscreen zoomable image viewer opened by tapping an inline [RemoteImage]. Supports
244 * pinch-to-zoom and pan via [detectTransformGestures], double-tap to toggle zoom, and
245 * re-centers when zoom returns to 1×. Uses a non-interactive Dialog window so it overlays
246 * the whole screen.
247 */
248@Composable
249private fun FullscreenImageViewer(
250 request: ImageRequest,
251 contentDescription: String,
252 onDismiss: () -> Unit,
253) {
254 // Scale is an Animatable so a double-tap smoothly tweens between 1× and the zoom target,
255 // while pinch-to-zoom drives it via snapTo (no animation) for immediate 1:1 finger tracking.
256 val scale = remember { Animatable(1f) }
257 var offsetX by remember { mutableFloatStateOf(0f) }
258 var offsetY by remember { mutableFloatStateOf(0f) }
259 val scope = rememberCoroutineScope()
260 // Double-tap zoom tween, collapsed to an instant snap under reduced motion. Resolved in
261 // the composable body (not inside the pointerInput coroutine) since it reads the
262 // composition's LocalReducedMotion.
263 val zoomSpec = rememberMotionTween<Float>(NetworkConfig.imageViewerZoomAnimMs)
264 // Swipe-to-dismiss: track the vertical drag offset. When the image is at 1× (not zoomed
265 // or panned) and the user drags down beyond a threshold, dismiss the viewer — a common
266 // gesture for photo viewers. The drag also fades the background so the dismiss reads as
267 // a continuous motion rather than a snap.
268 var dragOffsetY by remember { mutableFloatStateOf(0f) }
269 val dismissThreshold = 200f
270 Dialog(
271 onDismissRequest = onDismiss,
272 properties = DialogProperties(usePlatformDefaultWidth = false, decorFitsSystemWindows = false),
273 ) {
274 Box(
275 modifier = Modifier
276 .fillMaxSize()
277 .background(androidx.compose.ui.graphics.Color.Black.copy(alpha = (1f - (dragOffsetY.absoluteValue / dismissThreshold)).coerceIn(0f, 1f)))
278 .pointerInput(Unit) {
279 detectTransformGestures { _, pan, zoom, _ ->
280 // Clamp the zoom so the image can't be scaled down below 1× (which would
281 // leave empty letterbox) or blown up so far it becomes a handful of pixels.
282 val newScale = (scale.value * zoom).coerceIn(1f, NetworkConfig.imageViewerMaxZoom)
283 scope.launch { scale.snapTo(newScale) }
284 if (newScale <= 1f) {
285 // Returning to 1× recenters so a prior pan doesn't leave the image
286 // stuck off-center at its rest size.
287 offsetX = 0f
288 offsetY = 0f
289 } else {
290 offsetX += pan.x
291 offsetY += pan.y
292 }
293 }
294 }
295 // Swipe-to-dismiss: only when the image is at 1× (not zoomed/panned), a
296 // vertical drag accumulates an offset; crossing the threshold dismisses.
297 .pointerInput(scale.value) {
298 if (scale.value <= 1.001f) {
299 detectVerticalDragGestures(
300 onDragEnd = {
301 if (dragOffsetY.absoluteValue >= dismissThreshold) {
302 onDismiss()
303 } else {
304 // Animate the snap-back to rest. A simple reset; the user's
305 // finger is already lifting so a full spring isn't needed.
306 dragOffsetY = 0f
307 }
308 },
309 onVerticalDrag = { _, dragAmount ->
310 // Only accumulate downward drags (positive Y) for dismiss, so an
311 // upward swipe doesn't accidentally trigger it.
312 if (dragAmount > 0) dragOffsetY += dragAmount
313 },
314 )
315 }
316 }
317 // Tap handling on the same surface: a double-tap toggles zoom (animated, with a
318 // recenter on the way back to 1×); a single tap dismisses the viewer. detectTapGestures
319 // defers the single-tap callback until it knows the touch isn't the first of a pair.
320 .pointerInput(Unit) {
321 detectTapGestures(
322 onDoubleTap = {
323 scope.launch {
324 if (scale.value > 1.001f) {
325 offsetX = 0f
326 offsetY = 0f
327 scale.animateTo(1f, zoomSpec)
328 } else {
329 scale.animateTo(NetworkConfig.imageViewerDoubleTapZoom, zoomSpec)
330 }
331 }
332 },
333 onTap = { onDismiss() },
334 )
335 },
336 contentAlignment = Alignment.Center,
337 ) {
338 SubcomposeAsyncImage(
339 model = request,
340 contentDescription = contentDescription,
341 contentScale = ContentScale.Fit,
342 modifier = Modifier
343 .fillMaxSize()
344 .graphicsLayer(
345 scaleX = scale.value,
346 scaleY = scale.value,
347 translationX = offsetX,
348 // Apply the swipe-drag offset on top of any pan offset so the image
349 // follows the finger during a dismiss swipe.
350 translationY = offsetY + dragOffsetY,
351 ),
352 )
353 // Close button in the top corner, inset below the status bar so it doesn't sit
354 // under the notch / status bar overlay on edge-to-edge devices.
355 androidx.compose.material3.IconButton(
356 onClick = onDismiss,
357 modifier = Modifier
358 .align(Alignment.TopEnd)
359 .windowInsetsPadding(WindowInsets.statusBars)
360 .padding(end = 8.dp),
361 ) {
362 Icon(
363 Icons.Filled.Close,
364 contentDescription = stringResource(R.string.close),
365 tint = androidx.compose.ui.graphics.Color.White,
366 )
367 }
368 }
369 }
372/** Error slot for [RemoteImage]: a broken-image icon plus a "Tap to retry" caption, the whole box
373 * clickable so a failed load can be re-issued instead of stranding a permanent broken icon. */
374@Composable
375private fun ImageRetry(onRetry: () -> Unit) {
376 val label = stringResource(R.string.tap_to_retry)
377 Box(
378 modifier = Modifier
379 .fillMaxWidth()
380 .heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp)
381 .clickable(role = Role.Button, onClick = onRetry),
382 contentAlignment = Alignment.Center,
383 ) {
384 Column(horizontalAlignment = Alignment.CenterHorizontally) {
385 BrokenImageIcon()
386 Text(
387 label,
388 style = MaterialTheme.typography.labelSmall,
389 color = MaterialTheme.colorScheme.onSurfaceVariant,
390 modifier = Modifier.padding(top = 4.dp),
391 )
392 }
393 }
396/** Sentinel for [RemoteImage]'s produceState while resolveModel runs off-thread, so the initial
397 * (still-resolving) frame is distinguishable from a resolved-to-null (unloadable) result. */
398private val ImageResolving = Any()
400/** A centered, fixed-min-height box matching the loading/error slots so an image placeholder
401 * reserves the same space whether it's loading, failed, or unresolvable. */
402@Composable
403private fun ImageStatusBox(content: @Composable () -> Unit) {
404 Box(
405 modifier = Modifier.fillMaxWidth().heightIn(min = NetworkConfig.inlineImageMinHeightDp.dp),
406 contentAlignment = Alignment.Center,
407 ) { content() }
410@Composable
411private fun BrokenImageIcon() {
412 Icon(
413 Icons.Filled.BrokenImage,
414 contentDescription = stringResource(R.string.image_failed),
415 tint = MaterialTheme.colorScheme.onSurfaceVariant,
416 )